1#![forbid(unsafe_code)]
41
42use forensicnomicon::report::{Category, ExternalRef, Finding, Severity, Source};
43use peripheral_core::{Bus, DeviceConnection};
44use shellhist_core::HistoryEntry;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Action {
49 Executed,
51 Accessed,
53 Connected,
55 Searched,
57 Typed,
59 HistoryTampered,
61 MenuSelected,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub enum Subject {
73 Command(String),
75 File {
80 path: String,
82 volume_serial: Option<u32>,
84 },
85 Folder {
88 path: String,
90 volume_serial: Option<u32>,
92 },
93 Device {
97 id: String,
99 volume_serial: Option<u32>,
101 },
102 Query(String),
104}
105
106impl Subject {
107 #[must_use]
109 pub fn file(path: impl Into<String>) -> Self {
110 Self::File {
111 path: path.into(),
112 volume_serial: None,
113 }
114 }
115
116 #[must_use]
118 pub fn folder(path: impl Into<String>) -> Self {
119 Self::Folder {
120 path: path.into(),
121 volume_serial: None,
122 }
123 }
124}
125
126#[non_exhaustive]
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132pub enum SourceKind {
133 ShellHistory,
135 PeripheralDevice,
137 Srum,
140 Registry,
143 LnkFile,
146 JumpList,
150 BiomeMenuItem,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct UserActivity {
165 pub timestamp: Option<i64>,
168 pub actor: Option<String>,
171 pub action: Action,
173 pub subject: Subject,
175 pub source: SourceKind,
177 pub detail: String,
179}
180
181pub trait ActivitySource {
187 fn activities(&self) -> Vec<UserActivity>;
189}
190
191fn is_history_tamper(cmd: &str) -> bool {
197 let c = cmd.to_ascii_lowercase();
198 let c = c.trim();
199 c.contains("unset histfile")
201 || c.contains("histfile=/dev/null")
202 || c.contains("histsize=0")
203 || c.contains("histfilesize=0")
204 || (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
205 || c.contains("history -c")
206 || (c.contains("clear-history"))
208 || (c.contains("remove-item") && c.contains("consolehost_history"))
209 || (c.contains("rm ") && c.contains(".bash_history"))
211 || (c.contains("rm ") && c.contains(".zsh_history"))
212 || (c.starts_with("> ") && c.contains("history"))
213}
214
215pub struct ShellHistorySource<'a> {
221 entries: &'a [HistoryEntry],
222 actor: Option<String>,
223}
224
225impl<'a> ShellHistorySource<'a> {
226 #[must_use]
228 pub fn new(entries: &'a [HistoryEntry]) -> Self {
229 Self {
230 entries,
231 actor: None,
232 }
233 }
234
235 #[must_use]
237 pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
238 Self {
239 entries,
240 actor: Some(actor.into()),
241 }
242 }
243}
244
245impl ActivitySource for ShellHistorySource<'_> {
246 fn activities(&self) -> Vec<UserActivity> {
247 from_shell_history(self.entries, self.actor.as_deref())
248 }
249}
250
251#[must_use]
257pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
258 entries
259 .iter()
260 .map(|e| {
261 let action = if is_history_tamper(&e.command) {
262 Action::HistoryTampered
263 } else {
264 Action::Executed
265 };
266 UserActivity {
267 timestamp: e.timestamp,
268 actor: actor.map(ToString::to_string),
269 action,
270 subject: Subject::Command(e.command.clone()),
271 source: SourceKind::ShellHistory,
272 detail: e.command.clone(),
273 }
274 })
275 .collect()
276}
277
278pub struct DeviceSource<'a> {
284 connections: &'a [DeviceConnection],
285}
286
287impl<'a> DeviceSource<'a> {
288 #[must_use]
290 pub fn new(connections: &'a [DeviceConnection]) -> Self {
291 Self { connections }
292 }
293}
294
295impl ActivitySource for DeviceSource<'_> {
296 fn activities(&self) -> Vec<UserActivity> {
297 from_device_connections(self.connections)
298 }
299}
300
301#[must_use]
307pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
308 connections
309 .iter()
310 .map(|c| {
311 let timestamp = c
312 .first_install
313 .or(c.last_arrival)
314 .or(c.last_install)
315 .map(|s| s.value);
316 UserActivity {
317 timestamp,
318 actor: None,
319 action: Action::Connected,
320 subject: Subject::Device {
321 id: c.device_instance_id.clone(),
322 volume_serial: c.volume_serial,
323 },
324 source: SourceKind::PeripheralDevice,
325 detail: c.device_instance_id.clone(),
326 }
327 })
328 .collect()
329}
330
331pub struct SrumSource<'a> {
340 network: &'a [srum_core::NetworkUsageRecord],
341 app_usage: &'a [srum_core::AppUsageRecord],
342 id_map: &'a [srum_core::IdMapEntry],
343}
344
345impl<'a> SrumSource<'a> {
346 #[must_use]
348 pub fn new(
349 network: &'a [srum_core::NetworkUsageRecord],
350 app_usage: &'a [srum_core::AppUsageRecord],
351 id_map: &'a [srum_core::IdMapEntry],
352 ) -> Self {
353 Self {
354 network,
355 app_usage,
356 id_map,
357 }
358 }
359}
360
361impl ActivitySource for SrumSource<'_> {
362 fn activities(&self) -> Vec<UserActivity> {
363 from_srum(self.network, self.app_usage, self.id_map)
364 }
365}
366
367fn resolve_id(id: i32, id_map: &[srum_core::IdMapEntry]) -> Option<String> {
372 id_map
373 .iter()
374 .find(|e| e.id == id)
375 .map(|e| e.name.clone())
376 .filter(|n| !n.is_empty())
377}
378
379#[must_use]
388pub fn from_srum(
389 network: &[srum_core::NetworkUsageRecord],
390 app_usage: &[srum_core::AppUsageRecord],
391 id_map: &[srum_core::IdMapEntry],
392) -> Vec<UserActivity> {
393 let mut acts = Vec::with_capacity(network.len() + app_usage.len());
394
395 for r in network {
396 let actor =
397 resolve_id(r.user_id, id_map).unwrap_or_else(|| format!("user-id:{}", r.user_id));
398 let app = resolve_id(r.app_id, id_map).unwrap_or_else(|| format!("app-id:{}", r.app_id));
399 acts.push(UserActivity {
400 timestamp: Some(r.timestamp.timestamp()),
401 actor: Some(actor),
402 action: Action::Executed,
403 subject: Subject::Command(app),
404 source: SourceKind::Srum,
405 detail: format!(
406 "{}\u{2191} / {}\u{2193} bytes (SRUM network usage)",
407 r.bytes_sent, r.bytes_recv
408 ),
409 });
410 }
411
412 for r in app_usage {
413 let actor =
414 resolve_id(r.user_id, id_map).unwrap_or_else(|| format!("user-id:{}", r.user_id));
415 let app = resolve_id(r.app_id, id_map).unwrap_or_else(|| format!("app-id:{}", r.app_id));
416 acts.push(UserActivity {
417 timestamp: Some(r.timestamp.timestamp()),
418 actor: Some(actor),
419 action: Action::Executed,
420 subject: Subject::Command(app),
421 source: SourceKind::Srum,
422 detail: format!(
423 "{} foreground / {} background CPU cycles (SRUM app usage)",
424 r.foreground_cycles, r.background_cycles
425 ),
426 });
427 }
428
429 acts
430}
431
432pub struct LnkSource<'a> {
440 links: &'a [lnk_core::ShellLink],
441 actor: Option<String>,
442}
443
444impl<'a> LnkSource<'a> {
445 #[must_use]
447 pub fn new(links: &'a [lnk_core::ShellLink], actor: Option<&str>) -> Self {
448 Self {
449 links,
450 actor: actor.map(ToString::to_string),
451 }
452 }
453}
454
455impl ActivitySource for LnkSource<'_> {
456 fn activities(&self) -> Vec<UserActivity> {
457 from_lnk(self.links, self.actor.as_deref())
458 }
459}
460
461#[must_use]
470pub fn from_lnk(links: &[lnk_core::ShellLink], actor: Option<&str>) -> Vec<UserActivity> {
471 links
472 .iter()
473 .filter_map(|link| {
474 let info = link.link_info.as_ref()?;
475 let path = info.local_base_path.clone().or_else(|| {
476 info.common_network_relative_link
477 .as_ref()
478 .and_then(|c| c.net_name.clone())
479 })?;
480 let volume_serial = info.volume_id.as_ref().map(|v| v.drive_serial_number);
481 let timestamp = (link.header.write_time != 0).then_some(link.header.write_time);
483 Some(UserActivity {
484 timestamp,
485 actor: actor.map(ToString::to_string),
486 action: Action::Accessed,
487 subject: Subject::File {
488 path: path.clone(),
489 volume_serial,
490 },
491 source: SourceKind::LnkFile,
492 detail: format!("LNK target: {path}"),
493 })
494 })
495 .collect()
496}
497
498pub struct JumpListSource<'a> {
503 lists: &'a [lnk_core::JumpList],
504 actor: Option<String>,
505}
506
507impl<'a> JumpListSource<'a> {
508 #[must_use]
510 pub fn new(lists: &'a [lnk_core::JumpList], actor: Option<&str>) -> Self {
511 Self {
512 lists,
513 actor: actor.map(ToString::to_string),
514 }
515 }
516}
517
518impl ActivitySource for JumpListSource<'_> {
519 fn activities(&self) -> Vec<UserActivity> {
520 from_jumplists(self.lists, self.actor.as_deref())
521 }
522}
523
524#[must_use]
534pub fn from_jumplists(lists: &[lnk_core::JumpList], actor: Option<&str>) -> Vec<UserActivity> {
535 let mut out = Vec::new();
536 for list in lists {
537 let app = list.app_id.as_deref().map_or_else(
538 || "unknown app".to_string(),
539 |id| {
540 forensicnomicon::jumplist::appid_name(id)
541 .map_or_else(|| format!("AppID {id}"), ToString::to_string)
542 },
543 );
544 for entry in &list.entries {
545 let info = entry.link.link_info.as_ref();
546 let link_path = info.and_then(|i| {
547 i.local_base_path.clone().or_else(|| {
548 i.common_network_relative_link
549 .as_ref()
550 .and_then(|c| c.net_name.clone())
551 })
552 });
553 let path = match entry.destlist.as_ref() {
555 Some(d) if !d.path.is_empty() => Some(d.path.clone()),
556 _ => link_path,
557 };
558 let Some(path) = path else { continue };
559
560 let volume_serial =
561 info.and_then(|i| i.volume_id.as_ref().map(|v| v.drive_serial_number));
562 let dl_ts = entry
565 .destlist
566 .as_ref()
567 .and_then(|d| (d.last_access != 0).then_some(d.last_access));
568 let timestamp = dl_ts.or_else(|| {
569 (entry.link.header.write_time != 0).then_some(entry.link.header.write_time)
570 });
571
572 let detail = match entry.destlist.as_ref() {
573 Some(d) => format!("JumpList ({app}) recent item on {}: {path}", d.hostname),
574 None => format!("JumpList ({app}) recent item: {path}"),
575 };
576 out.push(UserActivity {
577 timestamp,
578 actor: actor.map(ToString::to_string),
579 action: Action::Accessed,
580 subject: Subject::File {
581 path,
582 volume_serial,
583 },
584 source: SourceKind::JumpList,
585 detail,
586 });
587 }
588 }
589 out
590}
591
592fn iso8601_to_epoch(s: Option<&str>) -> Option<i64> {
597 let s = s?;
598 chrono::DateTime::parse_from_rfc3339(s)
599 .ok()
600 .map(|dt| dt.timestamp())
601}
602
603pub struct RegistrySource<'a> {
615 userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
616 typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
617 shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
618 actor: Option<String>,
619}
620
621impl<'a> RegistrySource<'a> {
622 #[must_use]
625 pub fn new(
626 userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
627 typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
628 shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
629 actor: Option<&str>,
630 ) -> Self {
631 Self {
632 userassist,
633 typed_urls,
634 shellbags,
635 actor: actor.map(ToString::to_string),
636 }
637 }
638}
639
640impl ActivitySource for RegistrySource<'_> {
641 fn activities(&self) -> Vec<UserActivity> {
642 from_registry(
643 self.userassist,
644 self.typed_urls,
645 self.shellbags,
646 self.actor.as_deref(),
647 )
648 }
649}
650
651#[must_use]
657pub fn from_userassist(
658 entries: &[winreg_artifacts::userassist::UserAssistEntry],
659 actor: Option<&str>,
660) -> Vec<UserActivity> {
661 entries
662 .iter()
663 .map(|e| UserActivity {
664 timestamp: iso8601_to_epoch(e.last_run.as_deref()),
665 actor: actor.map(ToString::to_string),
666 action: Action::Executed,
667 subject: Subject::Command(e.program.clone()),
668 source: SourceKind::Registry,
669 detail: format!("UserAssist: {} run {} time(s)", e.program, e.run_count),
670 })
671 .collect()
672}
673
674#[must_use]
680pub fn from_typed_urls(
681 urls: &[winreg_artifacts::typed_urls::TypedUrl],
682 actor: Option<&str>,
683) -> Vec<UserActivity> {
684 urls.iter()
685 .map(|u| {
686 let detail = match &u.suspicious_reason {
687 Some(reason) => format!("TypedURL: {} ({reason})", u.url),
688 None => format!("TypedURL: {}", u.url),
689 };
690 UserActivity {
691 timestamp: iso8601_to_epoch(u.last_visited.as_deref()),
692 actor: actor.map(ToString::to_string),
693 action: Action::Typed,
694 subject: Subject::Query(u.url.clone()),
695 source: SourceKind::Registry,
696 detail,
697 }
698 })
699 .collect()
700}
701
702#[must_use]
707pub fn from_shellbags(
708 bags: &[winreg_artifacts::shellbags::ShellbagEntry],
709 actor: Option<&str>,
710) -> Vec<UserActivity> {
711 bags.iter()
712 .map(|b| UserActivity {
713 timestamp: iso8601_to_epoch(b.last_written.as_deref()),
714 actor: actor.map(ToString::to_string),
715 action: Action::Accessed,
716 subject: Subject::folder(b.path.clone()),
717 source: SourceKind::Registry,
718 detail: format!("ShellBag {}: {}", b.key_path, b.path),
719 })
720 .collect()
721}
722
723#[must_use]
728pub fn from_registry(
729 userassist: &[winreg_artifacts::userassist::UserAssistEntry],
730 typed_urls: &[winreg_artifacts::typed_urls::TypedUrl],
731 shellbags: &[winreg_artifacts::shellbags::ShellbagEntry],
732 actor: Option<&str>,
733) -> Vec<UserActivity> {
734 let mut acts = from_userassist(userassist, actor);
735 acts.extend(from_typed_urls(typed_urls, actor));
736 acts.extend(from_shellbags(shellbags, actor));
737 acts
738}
739
740pub struct BiomeMenuItemSource<'a> {
753 records: &'a [segb::menuitem::AppMenuItemRecord],
754 actor: Option<String>,
755}
756
757impl<'a> BiomeMenuItemSource<'a> {
758 #[must_use]
760 pub fn new(
761 records: &'a [segb::menuitem::AppMenuItemRecord],
762 actor: Option<&str>,
763 ) -> Self {
764 Self {
765 records,
766 actor: actor.map(ToString::to_string),
767 }
768 }
769}
770
771impl ActivitySource for BiomeMenuItemSource<'_> {
772 fn activities(&self) -> Vec<UserActivity> {
773 from_biome_menu_items(self.records, self.actor.as_deref())
774 }
775}
776
777#[must_use]
792pub fn from_biome_menu_items(
793 records: &[segb::menuitem::AppMenuItemRecord],
794 actor: Option<&str>,
795) -> Vec<UserActivity> {
796 records
797 .iter()
798 .filter_map(|r| {
799 let menu_item = r.menu_item.as_deref()?;
800 let label = match r.application.as_deref() {
801 Some(app) => format!("{app}: {menu_item}"),
802 None => menu_item.to_string(),
803 };
804 let timestamp = r
805 .timestamp_unix
806 .and_then(|t| t.is_finite().then_some(t as i64));
807 Some(UserActivity {
808 timestamp,
809 actor: actor.map(ToString::to_string),
810 action: Action::MenuSelected,
811 subject: Subject::Command(label.clone()),
812 source: SourceKind::BiomeMenuItem,
813 detail: label,
814 })
815 })
816 .collect()
817}
818
819#[must_use]
825pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
826 let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
827 events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
830 events
831}
832
833pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
839
840pub const NETWORK_EXFIL_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
849
850#[must_use]
852pub fn source(scope: impl Into<String>) -> Source {
853 Source {
854 analyzer: "useract-forensic".to_string(),
855 scope: scope.into(),
856 version: Some(env!("CARGO_PKG_VERSION").to_string()),
857 }
858}
859
860#[must_use]
873pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
874 let mut pairs = Vec::new();
875 for (di, dev) in events.iter().enumerate() {
876 let Subject::Device {
877 volume_serial: Some(dev_serial),
878 ..
879 } = &dev.subject
880 else {
881 continue;
882 };
883 for (fi, file) in events.iter().enumerate() {
884 if file_volume_serial(file) == Some(*dev_serial) {
885 pairs.push((di, fi));
886 }
887 }
888 }
889 pairs
890}
891
892fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
896 let structured = match &activity.subject {
897 Subject::File { volume_serial, .. } | Subject::Folder { volume_serial, .. } => {
898 *volume_serial
899 }
900 _ => return None,
901 };
902 if structured.is_some() {
903 return structured;
904 }
905 for tok in activity.detail.split_whitespace() {
906 if let Some(rest) = tok.strip_prefix("vol:") {
907 if let Ok(serial) = rest.parse::<u32>() {
908 return Some(serial);
909 }
910 }
911 }
912 None
913}
914
915#[must_use]
928pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
929 audit_with(events, &source("host"))
930}
931
932#[must_use]
934pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
935 let mut findings = Vec::new();
936
937 let media_windows: Vec<(i64, &str)> = events
945 .iter()
946 .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
947 (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
948 Some((ts, id.as_str()))
949 }
950 _ => None,
951 })
952 .collect();
953
954 for (di, fi) in device_file_volume_joins(events) {
957 findings.push(file_on_external_device_finding(
958 &events[di],
959 &events[fi],
960 src,
961 ));
962 }
963
964 for event in events {
965 if event.action == Action::HistoryTampered {
967 findings.push(history_tampered_finding(event, src));
968 continue;
969 }
970
971 if event.source == SourceKind::Srum {
974 if let Some(bytes_sent) = srum_network_bytes_sent(event) {
975 if bytes_sent >= NETWORK_EXFIL_BYTES_THRESHOLD {
976 findings.push(network_exfil_volume_finding(event, bytes_sent, src));
977 }
978 }
979 }
980
981 if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
983 (event.action, event.timestamp, &event.subject)
984 {
985 if let Some((win_ts, dev_id)) = media_windows
986 .iter()
987 .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
988 {
989 findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
990 }
991 }
992 }
993
994 findings
995}
996
997fn is_mass_storage_id(instance_id: &str) -> bool {
1003 let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
1004 Bus::from_enumerator(enumerator).is_mass_storage()
1005}
1006
1007fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
1008 let cmd = match &event.subject {
1009 Subject::Command(c) => c.as_str(),
1010 _ => event.detail.as_str(),
1011 };
1012 Finding::observation(
1013 Severity::Medium,
1014 Category::Concealment,
1015 "USERACT-HISTORY-TAMPERED",
1016 )
1017 .source(src.clone())
1018 .note(format!(
1019 "user activity {cmd:?} disables or clears the activity record; consistent with \
1020 anti-forensic history tampering (MITRE T1070.003)"
1021 ))
1022 .evidence("command", cmd.to_string())
1023 .external_ref(ExternalRef::mitre_attack("T1070.003"))
1024 .build()
1025}
1026
1027fn exec_during_media_finding(
1028 cmd: &str,
1029 cmd_ts: i64,
1030 dev_ts: i64,
1031 dev_id: &str,
1032 src: &Source,
1033) -> Finding {
1034 Finding::observation(
1035 Severity::Low,
1036 Category::Threat,
1037 "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
1038 )
1039 .source(src.clone())
1040 .note(format!(
1041 "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
1042 device {dev_id:?} being connected; consistent with activity involving external media \
1043 (MITRE T1052 / T1091)"
1044 ))
1045 .evidence("command", cmd.to_string())
1046 .evidence("device", dev_id.to_string())
1047 .evidence("command_epoch", cmd_ts.to_string())
1048 .evidence("device_epoch", dev_ts.to_string())
1049 .external_ref(ExternalRef::mitre_attack("T1052"))
1050 .external_ref(ExternalRef::mitre_attack("T1091"))
1051 .build()
1052}
1053
1054fn srum_network_bytes_sent(activity: &UserActivity) -> Option<u64> {
1058 let prefix = activity.detail.split('\u{2191}').next()?;
1059 prefix.trim().parse::<u64>().ok()
1060}
1061
1062fn network_exfil_volume_finding(event: &UserActivity, bytes_sent: u64, src: &Source) -> Finding {
1063 let app = match &event.subject {
1064 Subject::Command(c) => c.as_str(),
1065 _ => event.detail.as_str(), };
1067 let actor = event.actor.as_deref().unwrap_or("(unattributed)");
1068 Finding::observation(
1069 Severity::Medium,
1070 Category::Threat,
1071 "USERACT-NETWORK-EXFIL-VOLUME",
1072 )
1073 .source(src.clone())
1074 .note(format!(
1075 "SRUM records {bytes_sent} bytes sent in one interval by {app:?} attributed to user \
1076 {actor:?}; the volume exceeds the {NETWORK_EXFIL_BYTES_THRESHOLD}-byte lead threshold and \
1077 is consistent with bulk data exfiltration (MITRE T1048 / T1052) — a graded lead for the \
1078 examiner, not a verdict"
1079 ))
1080 .evidence("application", app.to_string())
1081 .evidence("actor", actor.to_string())
1082 .evidence("bytes_sent", bytes_sent.to_string())
1083 .external_ref(ExternalRef::mitre_attack("T1048"))
1084 .external_ref(ExternalRef::mitre_attack("T1052"))
1085 .build()
1086}
1087
1088fn file_on_external_device_finding(
1089 device: &UserActivity,
1090 file: &UserActivity,
1091 src: &Source,
1092) -> Finding {
1093 let path = match &file.subject {
1094 Subject::File { path, .. } | Subject::Folder { path, .. } => path.as_str(),
1095 _ => file.detail.as_str(), };
1097 let dev_id = match &device.subject {
1098 Subject::Device { id, .. } => id.as_str(),
1099 _ => device.detail.as_str(), };
1101 let serial = match &device.subject {
1102 Subject::Device {
1103 volume_serial: Some(s),
1104 ..
1105 } => *s,
1106 _ => 0, };
1108 Finding::observation(
1109 Severity::Medium,
1110 Category::Threat,
1111 "USERACT-FILE-ON-EXTERNAL-DEVICE",
1112 )
1113 .source(src.clone())
1114 .note(format!(
1115 "a user accessed {path:?} on a volume (serial {serial:#010x}) whose serial matches the \
1116 connected external device {dev_id:?}; consistent with data movement to/from removable \
1117 media (MITRE T1052 / T1091)"
1118 ))
1119 .evidence("file", path.to_string())
1120 .evidence("device", dev_id.to_string())
1121 .evidence("volume_serial", format!("{serial:#010x}"))
1122 .external_ref(ExternalRef::mitre_attack("T1052"))
1123 .external_ref(ExternalRef::mitre_attack("T1091"))
1124 .build()
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130 use peripheral_core::{Bus, Provenance, Stamp};
1131 use shellhist_core::{HistoryEntry, Shell};
1132
1133 fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
1134 HistoryEntry {
1135 shell: Shell::Bash,
1136 command: cmd.to_string(),
1137 timestamp: ts,
1138 elapsed: None,
1139 paths: Vec::new(),
1140 }
1141 }
1142
1143 fn device(
1144 instance_id: &str,
1145 bus: Bus,
1146 first_install: Option<i64>,
1147 vol: Option<u32>,
1148 ) -> DeviceConnection {
1149 DeviceConnection {
1150 bus,
1151 device_class_guid: None,
1152 vid: None,
1153 pid: None,
1154 device_serial: None,
1155 serial_is_os_generated: false,
1156 friendly_name: None,
1157 device_instance_id: instance_id.to_string(),
1158 first_install: first_install.map(Stamp::authoritative),
1159 last_install: None,
1160 last_arrival: None,
1161 last_removal: None,
1162 parent_id_prefix: None,
1163 volume_guid: None,
1164 drive_letter: None,
1165 volume_serial: vol,
1166 disk_signature: None,
1167 dma_capable: bus.is_dma_capable(),
1168 mitre: Vec::new(),
1169 source: Provenance {
1170 file: "setupapi.dev.log".to_string(),
1171 line: 1,
1172 },
1173 }
1174 }
1175
1176 #[test]
1179 fn shell_command_becomes_executed_activity() {
1180 let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
1181 let acts = from_shell_history(&entries, None);
1182 assert_eq!(acts.len(), 1);
1183 assert_eq!(acts[0].action, Action::Executed);
1184 assert_eq!(acts[0].source, SourceKind::ShellHistory);
1185 assert_eq!(acts[0].timestamp, Some(1_700_000_000));
1186 assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
1187 assert_eq!(acts[0].actor, None);
1188 }
1189
1190 #[test]
1191 fn shell_actor_is_carried_when_known() {
1192 let entries = [entry("whoami", None)];
1193 let acts = from_shell_history(&entries, Some("alice"));
1194 assert_eq!(acts[0].actor.as_deref(), Some("alice"));
1195 }
1196
1197 #[test]
1198 fn history_clearing_command_becomes_tampered() {
1199 for cmd in [
1200 "unset HISTFILE",
1201 "history -c",
1202 "export HISTFILE=/dev/null",
1203 "Clear-History",
1204 "rm ~/.bash_history",
1205 ] {
1206 let entries = [entry(cmd, Some(1))];
1207 let acts = from_shell_history(&entries, None);
1208 assert_eq!(acts[0].action, Action::HistoryTampered);
1209 }
1210 }
1211
1212 #[test]
1213 fn benign_command_is_not_tampered() {
1214 let entries = [entry("git log --oneline", Some(1))];
1215 let acts = from_shell_history(&entries, None);
1216 assert_eq!(acts[0].action, Action::Executed);
1217 }
1218
1219 #[test]
1222 fn device_becomes_connected_with_volume_serial() {
1223 let conns = [device(
1224 "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
1225 Bus::Usb,
1226 Some(1_700_000_500),
1227 Some(0xDEAD_BEEF),
1228 )];
1229 let acts = from_device_connections(&conns);
1230 assert_eq!(acts.len(), 1);
1231 assert_eq!(acts[0].action, Action::Connected);
1232 assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
1233 assert_eq!(acts[0].timestamp, Some(1_700_000_500));
1234 assert_eq!(
1235 acts[0].subject,
1236 Subject::Device {
1237 id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
1238 volume_serial: Some(0xDEAD_BEEF),
1239 }
1240 );
1241 }
1242
1243 #[test]
1244 fn device_timestamp_falls_back_through_stamps() {
1245 let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
1246 conn.last_arrival = Some(Stamp::inferred(42));
1247 let acts = from_device_connections(&[conn]);
1248 assert_eq!(acts[0].timestamp, Some(42));
1249 }
1250
1251 #[test]
1252 fn device_without_any_stamp_has_no_timestamp() {
1253 let conn = device("USB\\VID_0781", Bus::Usb, None, None);
1254 let acts = from_device_connections(&[conn]);
1255 assert_eq!(acts[0].timestamp, None);
1256 }
1257
1258 #[test]
1261 fn timeline_merges_and_sorts_by_timestamp() {
1262 let entries = [entry("late", Some(300)), entry("early", Some(100))];
1263 let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
1264 let shell = ShellHistorySource::new(&entries);
1265 let devices = DeviceSource::new(&conns);
1266 let tl = build_timeline(&[&shell, &devices]);
1267 let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
1268 assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
1269 }
1270
1271 #[test]
1272 fn timeline_orders_untimestamped_events_last_and_stably() {
1273 let entries = [
1274 entry("no_ts_a", None),
1275 entry("ts", Some(50)),
1276 entry("no_ts_b", None),
1277 ];
1278 let shell = ShellHistorySource::new(&entries);
1279 let tl = build_timeline(&[&shell]);
1280 assert_eq!(tl[0].timestamp, Some(50));
1281 assert_eq!(tl[1].detail, "no_ts_a");
1282 assert_eq!(tl[2].detail, "no_ts_b");
1283 }
1284
1285 #[test]
1288 fn audit_surfaces_history_tampered() {
1289 let entries = [entry("unset HISTFILE", Some(10))];
1290 let acts = from_shell_history(&entries, None);
1291 let findings = audit(&acts);
1292 let f = findings
1293 .iter()
1294 .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
1295 .expect("history-tampered finding must fire");
1296 assert_eq!(f.severity, Some(Severity::Medium));
1297 assert_eq!(f.category, Category::Concealment);
1298 }
1299
1300 #[test]
1303 fn audit_fires_exec_during_removable_media_within_window() {
1304 let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
1305 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
1306 let shell = ShellHistorySource::new(&entries);
1307 let devices = DeviceSource::new(&conns);
1308 let tl = build_timeline(&[&shell, &devices]);
1309 let findings = audit(&tl);
1310 assert!(findings
1311 .iter()
1312 .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1313 }
1314
1315 #[test]
1316 fn audit_does_not_fire_outside_window() {
1317 let entries = [entry("ls", Some(1_000))];
1318 let conns = [device(
1319 "USBSTOR\\Disk",
1320 Bus::Usb,
1321 Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
1322 None,
1323 )];
1324 let shell = ShellHistorySource::new(&entries);
1325 let devices = DeviceSource::new(&conns);
1326 let tl = build_timeline(&[&shell, &devices]);
1327 let findings = audit(&tl);
1328 assert!(findings
1329 .iter()
1330 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1331 }
1332
1333 #[test]
1334 fn audit_does_not_fire_for_non_mass_storage_device() {
1335 let entries = [entry("ls", Some(1_000))];
1337 let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
1338 let shell = ShellHistorySource::new(&entries);
1339 let devices = DeviceSource::new(&conns);
1340 let tl = build_timeline(&[&shell, &devices]);
1341 let findings = audit(&tl);
1342 assert!(findings
1343 .iter()
1344 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1345 }
1346
1347 #[test]
1348 fn audit_with_custom_source_stamps_scope() {
1349 let entries = [entry("history -c", Some(1))];
1350 let acts = from_shell_history(&entries, None);
1351 let findings = audit_with(&acts, &source("CASE-001/host-7"));
1352 let f = &findings[0];
1353 assert_eq!(f.source.scope, "CASE-001/host-7");
1354 assert_eq!(f.source.analyzer, "useract-forensic");
1355 }
1356
1357 #[test]
1360 fn findings_are_hedged_observations_never_verdicts() {
1361 let entries = [
1362 entry("unset HISTFILE", Some(1_000)),
1363 entry("cp x /media/usb", Some(1_010)),
1364 ];
1365 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
1366 let shell = ShellHistorySource::new(&entries);
1367 let devices = DeviceSource::new(&conns);
1368 let tl = build_timeline(&[&shell, &devices]);
1369 let findings = audit(&tl);
1370 assert!(!findings.is_empty());
1371 for f in &findings {
1372 let note = f.note.to_ascii_lowercase();
1373 assert!(!note.contains("proves"));
1374 assert!(!note.contains("confirms"));
1375 assert!(!note.contains("definitely"));
1376 assert!(note.contains("consistent with"));
1377 }
1378 }
1379
1380 #[test]
1383 fn volume_serial_join_is_empty_for_v01_sources() {
1384 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1386 let acts = from_device_connections(&conns);
1387 assert!(device_file_volume_joins(&acts).is_empty());
1388 }
1389
1390 #[test]
1391 fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
1392 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1395 let mut acts = from_device_connections(&conns);
1396 acts.push(UserActivity {
1397 timestamp: Some(2),
1398 actor: None,
1399 action: Action::Accessed,
1400 subject: Subject::file("\\\\?\\E:\\secret.docx"),
1401 source: SourceKind::PeripheralDevice, detail: "opened E:\\secret.docx vol:4660".to_string(), });
1404 let joins = device_file_volume_joins(&acts);
1405 assert_eq!(joins, vec![(0, 1)]);
1406 }
1407
1408 #[test]
1409 fn volume_serial_join_ignores_mismatched_serials() {
1410 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1411 let mut acts = from_device_connections(&conns);
1412 acts.push(UserActivity {
1413 timestamp: Some(2),
1414 actor: None,
1415 action: Action::Accessed,
1416 subject: Subject::file("x"),
1417 source: SourceKind::PeripheralDevice,
1418 detail: "vol:9999".to_string(),
1419 });
1420 assert!(device_file_volume_joins(&acts).is_empty());
1421 }
1422
1423 #[test]
1424 fn volume_serial_join_skips_files_without_a_volume_token() {
1425 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1428 let mut acts = from_device_connections(&conns);
1429 acts.push(UserActivity {
1430 timestamp: Some(2),
1431 actor: None,
1432 action: Action::Accessed,
1433 subject: Subject::folder("E:\\photos"),
1434 source: SourceKind::PeripheralDevice,
1435 detail: "opened folder with no serial hint".to_string(),
1436 });
1437 acts.push(UserActivity {
1439 timestamp: Some(3),
1440 actor: None,
1441 action: Action::Accessed,
1442 subject: Subject::file("E:\\x"),
1443 source: SourceKind::PeripheralDevice,
1444 detail: "vol:notanumber".to_string(),
1445 });
1446 assert!(device_file_volume_joins(&acts).is_empty());
1447 }
1448
1449 #[test]
1450 fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
1451 let act = UserActivity {
1454 timestamp: Some(1),
1455 actor: None,
1456 action: Action::HistoryTampered,
1457 subject: Subject::file("ConsoleHost_history.txt"),
1458 source: SourceKind::ShellHistory,
1459 detail: "Remove-Item ConsoleHost_history.txt".to_string(),
1460 };
1461 let findings = audit(&[act]);
1462 assert_eq!(findings.len(), 1);
1463 assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
1464 assert!(findings[0]
1465 .note
1466 .contains("Remove-Item ConsoleHost_history.txt"));
1467 }
1468
1469 #[test]
1470 fn is_mass_storage_id_classifies_bare_and_separated_ids() {
1471 assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
1472 assert!(is_mass_storage_id("USBSTOR"));
1473 assert!(!is_mass_storage_id("BTHENUM\\Dev"));
1474 assert!(!is_mass_storage_id(""));
1475 }
1476
1477 #[test]
1478 fn activitysource_trait_dispatches() {
1479 let entries = [entry("ls", Some(1))];
1480 let s = ShellHistorySource::for_actor(&entries, "bob");
1481 let acts: Vec<UserActivity> = s.activities();
1482 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1483 }
1484
1485 use srum_core::{AppUsageRecord, IdMapEntry, NetworkUsageRecord};
1488
1489 fn utc(epoch: i64) -> chrono::DateTime<chrono::Utc> {
1490 chrono::DateTime::from_timestamp(epoch, 0).expect("valid epoch")
1491 }
1492
1493 #[test]
1494 fn srum_network_row_is_executed_and_actor_attributed() {
1495 let id_map = [
1497 IdMapEntry {
1498 id: 7,
1499 name: "S-1-5-21-1-2-3-1001".to_string(),
1500 },
1501 IdMapEntry {
1502 id: 42,
1503 name: "\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string(),
1504 },
1505 ];
1506 let net = [NetworkUsageRecord {
1507 app_id: 42,
1508 user_id: 7,
1509 timestamp: utc(1_700_000_000),
1510 bytes_sent: 4096,
1511 bytes_recv: 1024,
1512 auto_inc_id: 0,
1513 }];
1514 let acts = from_srum(&net, &[], &id_map);
1515 assert_eq!(acts.len(), 1);
1516 let a = &acts[0];
1517 assert_eq!(a.action, Action::Executed);
1518 assert_eq!(a.source, SourceKind::Srum);
1519 assert_eq!(a.timestamp, Some(1_700_000_000));
1520 assert_eq!(a.actor.as_deref(), Some("S-1-5-21-1-2-3-1001"));
1522 assert_eq!(
1524 a.subject,
1525 Subject::Command("\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string())
1526 );
1527 assert!(a.detail.contains("4096"));
1529 assert!(a.detail.contains("1024"));
1530 }
1531
1532 #[test]
1533 fn srum_unresolved_user_id_falls_back_to_numeric_token() {
1534 let net = [NetworkUsageRecord {
1536 app_id: 1,
1537 user_id: 99,
1538 timestamp: utc(10),
1539 bytes_sent: 1,
1540 bytes_recv: 2,
1541 auto_inc_id: 0,
1542 }];
1543 let acts = from_srum(&net, &[], &[]);
1544 assert_eq!(acts.len(), 1);
1545 assert_eq!(acts[0].actor.as_deref(), Some("user-id:99"));
1546 assert_eq!(acts[0].subject, Subject::Command("app-id:1".to_string()));
1548 }
1549
1550 #[test]
1551 fn srum_app_usage_row_is_executed_and_actor_attributed() {
1552 let id_map = [
1553 IdMapEntry {
1554 id: 5,
1555 name: "S-1-5-21-9-9-9-500".to_string(),
1556 },
1557 IdMapEntry {
1558 id: 8,
1559 name: "C:\\Tools\\rclone.exe".to_string(),
1560 },
1561 ];
1562 let app = [AppUsageRecord {
1563 app_id: 8,
1564 user_id: 5,
1565 timestamp: utc(1_700_000_500),
1566 foreground_cycles: 900_000,
1567 background_cycles: 100,
1568 auto_inc_id: 0,
1569 }];
1570 let acts = from_srum(&[], &app, &id_map);
1571 assert_eq!(acts.len(), 1);
1572 assert_eq!(acts[0].action, Action::Executed);
1573 assert_eq!(acts[0].source, SourceKind::Srum);
1574 assert_eq!(acts[0].actor.as_deref(), Some("S-1-5-21-9-9-9-500"));
1575 assert_eq!(
1576 acts[0].subject,
1577 Subject::Command("C:\\Tools\\rclone.exe".to_string())
1578 );
1579 }
1580
1581 #[test]
1582 fn srum_source_adapter_dispatches() {
1583 let net = [NetworkUsageRecord {
1584 app_id: 1,
1585 user_id: 1,
1586 timestamp: utc(1),
1587 bytes_sent: 1,
1588 bytes_recv: 1,
1589 auto_inc_id: 0,
1590 }];
1591 let s = SrumSource::new(&net, &[], &[]);
1592 let acts = s.activities();
1593 assert_eq!(acts.len(), 1);
1594 assert_eq!(acts[0].source, SourceKind::Srum);
1595 }
1596
1597 #[test]
1600 fn audit_fires_network_exfil_volume_above_threshold() {
1601 let id_map = [
1602 IdMapEntry {
1603 id: 7,
1604 name: "S-1-5-21-1-2-3-1001".to_string(),
1605 },
1606 IdMapEntry {
1607 id: 42,
1608 name: "rclone.exe".to_string(),
1609 },
1610 ];
1611 let net = [NetworkUsageRecord {
1612 app_id: 42,
1613 user_id: 7,
1614 timestamp: utc(1_700_000_000),
1615 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD + 1,
1616 bytes_recv: 0,
1617 auto_inc_id: 0,
1618 }];
1619 let acts = from_srum(&net, &[], &id_map);
1620 let findings = audit(&acts);
1621 let f = findings
1622 .iter()
1623 .find(|f| f.code == "USERACT-NETWORK-EXFIL-VOLUME")
1624 .expect("network-exfil-volume must fire above threshold");
1625 assert_eq!(f.severity, Some(Severity::Medium));
1626 assert_eq!(f.category, Category::Threat);
1627 }
1628
1629 #[test]
1630 fn audit_does_not_fire_network_exfil_below_threshold() {
1631 let net = [NetworkUsageRecord {
1632 app_id: 1,
1633 user_id: 1,
1634 timestamp: utc(1),
1635 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD - 1,
1636 bytes_recv: 0,
1637 auto_inc_id: 0,
1638 }];
1639 let acts = from_srum(&net, &[], &[]);
1640 let findings = audit(&acts);
1641 assert!(findings
1642 .iter()
1643 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1644 }
1645
1646 #[test]
1647 fn audit_skips_exfil_check_for_srum_app_usage_rows() {
1648 let app = [AppUsageRecord {
1652 app_id: 1,
1653 user_id: 1,
1654 timestamp: utc(1),
1655 foreground_cycles: u64::MAX,
1656 background_cycles: u64::MAX,
1657 auto_inc_id: 0,
1658 }];
1659 let acts = from_srum(&[], &app, &[]);
1660 let findings = audit(&acts);
1661 assert!(findings
1662 .iter()
1663 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1664 }
1665
1666 use winreg_artifacts::shellbags::ShellbagEntry;
1669 use winreg_artifacts::typed_urls::TypedUrl;
1670 use winreg_artifacts::userassist::UserAssistEntry;
1671
1672 fn ua(program: &str, run_count: u32, last_run: Option<&str>) -> UserAssistEntry {
1673 UserAssistEntry {
1674 program: program.to_string(),
1675 run_count,
1676 focus_count: 0,
1677 focus_duration_ms: 0,
1678 last_run: last_run.map(ToString::to_string),
1679 guid: "{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}".to_string(),
1680 }
1681 }
1682
1683 #[test]
1684 fn userassist_entry_becomes_executed_with_run_count() {
1685 let entries = [ua(
1686 "C:\\Windows\\System32\\cmd.exe",
1687 5,
1688 Some("2024-06-15T08:00:00Z"),
1689 )];
1690 let acts = from_userassist(&entries, Some("alice"));
1691 assert_eq!(acts.len(), 1);
1692 let a = &acts[0];
1693 assert_eq!(a.action, Action::Executed);
1694 assert_eq!(a.source, SourceKind::Registry);
1695 assert_eq!(
1696 a.subject,
1697 Subject::Command("C:\\Windows\\System32\\cmd.exe".to_string())
1698 );
1699 assert_eq!(a.timestamp, Some(1_718_438_400));
1701 assert_eq!(a.actor.as_deref(), Some("alice"));
1702 assert!(a.detail.contains('5'));
1704 }
1705
1706 #[test]
1707 fn userassist_without_last_run_has_no_timestamp() {
1708 let entries = [ua("notepad.exe", 1, None)];
1709 let acts = from_userassist(&entries, None);
1710 assert_eq!(acts[0].timestamp, None);
1711 assert_eq!(acts[0].actor, None);
1712 }
1713
1714 #[test]
1715 fn typed_url_becomes_typed_activity() {
1716 let urls = [TypedUrl {
1717 url: "https://pastebin.com/abc".to_string(),
1718 last_visited: Some("2024-01-02T03:04:05Z".to_string()),
1719 is_suspicious: true,
1720 suspicious_reason: Some("suspicious domain: pastebin.com".to_string()),
1721 }];
1722 let acts = from_typed_urls(&urls, None);
1723 assert_eq!(acts.len(), 1);
1724 assert_eq!(acts[0].action, Action::Typed);
1725 assert_eq!(acts[0].source, SourceKind::Registry);
1726 assert_eq!(
1727 acts[0].subject,
1728 Subject::Query("https://pastebin.com/abc".to_string())
1729 );
1730 assert!(acts[0].timestamp.is_some());
1731 }
1732
1733 #[test]
1734 fn shellbag_becomes_accessed_folder() {
1735 let bags = [ShellbagEntry {
1736 path: "BagMRU[slot=0, size=120 bytes]".to_string(),
1737 key_path: "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0".to_string(),
1738 last_written: Some("2024-03-04T05:06:07Z".to_string()),
1739 mru_order: vec!["0".to_string()],
1740 }];
1741 let acts = from_shellbags(&bags, Some("bob"));
1742 assert_eq!(acts.len(), 1);
1743 assert_eq!(acts[0].action, Action::Accessed);
1744 assert_eq!(acts[0].source, SourceKind::Registry);
1745 assert!(matches!(acts[0].subject, Subject::Folder { .. }));
1746 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1747 }
1748
1749 #[test]
1750 fn from_registry_merges_all_three_registry_artifacts() {
1751 let ua_entries = [ua("cmd.exe", 1, Some("2024-06-15T08:00:00Z"))];
1752 let urls = [TypedUrl {
1753 url: "https://x.test".to_string(),
1754 last_visited: None,
1755 is_suspicious: false,
1756 suspicious_reason: None,
1757 }];
1758 let bags = [ShellbagEntry {
1759 path: "BagMRU[slot=0, size=10 bytes]".to_string(),
1760 key_path: "k".to_string(),
1761 last_written: None,
1762 mru_order: vec![],
1763 }];
1764 let acts = from_registry(&ua_entries, &urls, &bags, Some("alice"));
1765 assert_eq!(acts.len(), 3);
1766 assert!(acts.iter().any(|a| a.action == Action::Executed));
1767 assert!(acts.iter().any(|a| a.action == Action::Typed));
1768 assert!(acts.iter().any(|a| a.action == Action::Accessed));
1769 assert!(acts.iter().all(|a| a.source == SourceKind::Registry));
1770 assert!(acts.iter().all(|a| a.actor.as_deref() == Some("alice")));
1771 }
1772
1773 #[test]
1774 fn registry_source_adapter_dispatches() {
1775 let ua_entries = [ua("cmd.exe", 1, None)];
1776 let s = RegistrySource::new(&ua_entries, &[], &[], None);
1777 let acts = s.activities();
1778 assert_eq!(acts.len(), 1);
1779 assert_eq!(acts[0].source, SourceKind::Registry);
1780 }
1781
1782 use lnk_core::{LinkInfo, ShellLink, ShellLinkHeader, StringData, VolumeId};
1785
1786 fn shell_link(
1787 local_base_path: Option<&str>,
1788 drive_serial: Option<u32>,
1789 write_time: i64,
1790 net_name: Option<&str>,
1791 ) -> ShellLink {
1792 let volume_id = drive_serial.map(|s| VolumeId {
1793 drive_type: lnk_core::drive_type::REMOVABLE,
1794 drive_serial_number: s,
1795 volume_label: None,
1796 });
1797 let cnrl = net_name.map(|n| lnk_core::CommonNetworkRelativeLink {
1798 net_name: Some(n.to_string()),
1799 device_name: None,
1800 });
1801 ShellLink {
1802 header: ShellLinkHeader {
1803 link_flags: 0,
1804 file_attributes: 0,
1805 creation_time: 0,
1806 access_time: 0,
1807 write_time,
1808 file_size: 0,
1809 icon_index: 0,
1810 show_command: 1,
1811 hotkey: 0,
1812 },
1813 link_target_idlist: None,
1814 link_info: Some(LinkInfo {
1815 volume_id,
1816 local_base_path: local_base_path.map(ToString::to_string),
1817 common_network_relative_link: cnrl,
1818 }),
1819 string_data: StringData::default(),
1820 tracker: None,
1821 }
1822 }
1823
1824 #[test]
1825 fn lnk_target_becomes_accessed_file_with_volume_serial() {
1826 let links = [shell_link(
1827 Some("E:\\secret.docx"),
1828 Some(0xDEAD_BEEF),
1829 1_700_000_000,
1830 None,
1831 )];
1832 let acts = from_lnk(&links, Some("alice"));
1833 assert_eq!(acts.len(), 1);
1834 let a = &acts[0];
1835 assert_eq!(a.action, Action::Accessed);
1836 assert_eq!(a.source, SourceKind::LnkFile);
1837 assert_eq!(a.timestamp, Some(1_700_000_000));
1839 assert_eq!(a.actor.as_deref(), Some("alice"));
1840 assert_eq!(
1842 a.subject,
1843 Subject::File {
1844 path: "E:\\secret.docx".to_string(),
1845 volume_serial: Some(0xDEAD_BEEF),
1846 }
1847 );
1848 }
1849
1850 #[test]
1851 fn lnk_without_volume_id_has_no_serial() {
1852 let links = [shell_link(Some("C:\\x.txt"), None, 0, None)];
1853 let acts = from_lnk(&links, None);
1854 assert_eq!(acts.len(), 1);
1855 assert_eq!(
1856 acts[0].subject,
1857 Subject::File {
1858 path: "C:\\x.txt".to_string(),
1859 volume_serial: None,
1860 }
1861 );
1862 assert_eq!(acts[0].timestamp, None);
1864 }
1865
1866 #[test]
1867 fn lnk_network_target_falls_back_to_unc_path() {
1868 let links = [shell_link(None, None, 5, Some("\\\\server\\share"))];
1870 let acts = from_lnk(&links, None);
1871 assert_eq!(acts.len(), 1);
1872 assert_eq!(
1873 acts[0].subject,
1874 Subject::File {
1875 path: "\\\\server\\share".to_string(),
1876 volume_serial: None,
1877 }
1878 );
1879 }
1880
1881 #[test]
1882 fn lnk_without_link_info_is_skipped() {
1883 let mut link = shell_link(None, None, 0, None);
1885 link.link_info = None;
1886 let acts = from_lnk(&[link], None);
1887 assert!(acts.is_empty());
1888 }
1889
1890 fn destlist(path: &str, host: &str, last_access: i64) -> lnk_core::DestListEntry {
1891 lnk_core::DestListEntry {
1892 droid_volume_guid: String::new(),
1893 droid_file_guid: String::new(),
1894 birth_droid_volume_guid: String::new(),
1895 birth_droid_file_guid: String::new(),
1896 hostname: host.to_string(),
1897 entry_number: 1,
1898 last_access,
1899 pinned: false,
1900 access_count: Some(3),
1901 path: path.to_string(),
1902 }
1903 }
1904
1905 #[test]
1906 fn jumplist_automatic_entry_becomes_accessed_file() {
1907 let link = shell_link(
1911 Some("C:\\Users\\bob\\q3.xlsx"),
1912 Some(0x1234_5678),
1913 1_700_000_000,
1914 None,
1915 );
1916 let lists = [lnk_core::JumpList {
1917 kind: lnk_core::JumpListKind::Automatic,
1918 app_id: Some("1b4dd67f29cb1962".to_string()),
1919 entries: vec![lnk_core::JumpListEntry {
1920 destlist: Some(destlist("C:\\Users\\bob\\q3.xlsx", "WS01", 1_700_000_500)),
1921 link,
1922 }],
1923 }];
1924 let acts = from_jumplists(&lists, Some("bob"));
1925 assert_eq!(acts.len(), 1);
1926 let a = &acts[0];
1927 assert_eq!(a.action, Action::Accessed);
1928 assert_eq!(a.source, SourceKind::JumpList);
1929 assert_eq!(a.timestamp, Some(1_700_000_500));
1932 assert_eq!(a.actor.as_deref(), Some("bob"));
1933 assert_eq!(
1934 a.subject,
1935 Subject::File {
1936 path: "C:\\Users\\bob\\q3.xlsx".to_string(),
1937 volume_serial: Some(0x1234_5678),
1938 }
1939 );
1940 }
1941
1942 #[test]
1943 fn jumplist_custom_entry_falls_back_to_embedded_link() {
1944 let link = shell_link(
1947 Some("D:\\report.pdf"),
1948 Some(0xAABB_CCDD),
1949 1_690_000_000,
1950 None,
1951 );
1952 let lists = [lnk_core::JumpList {
1953 kind: lnk_core::JumpListKind::Custom,
1954 app_id: None,
1955 entries: vec![lnk_core::JumpListEntry {
1956 destlist: None,
1957 link,
1958 }],
1959 }];
1960 let acts = from_jumplists(&lists, None);
1961 assert_eq!(acts.len(), 1);
1962 assert_eq!(acts[0].source, SourceKind::JumpList);
1963 assert_eq!(acts[0].timestamp, Some(1_690_000_000));
1964 assert_eq!(
1965 acts[0].subject,
1966 Subject::File {
1967 path: "D:\\report.pdf".to_string(),
1968 volume_serial: Some(0xAABB_CCDD),
1969 }
1970 );
1971 }
1972
1973 #[test]
1974 fn lnk_source_adapter_dispatches() {
1975 let links = [shell_link(Some("E:\\f"), Some(1), 1, None)];
1976 let s = LnkSource::new(&links, None);
1977 let acts = s.activities();
1978 assert_eq!(acts.len(), 1);
1979 assert_eq!(acts[0].source, SourceKind::LnkFile);
1980 }
1981
1982 #[test]
1985 fn lnk_file_joins_connected_device_on_volume_serial() {
1986 let links = [shell_link(
1987 Some("E:\\loot.zip"),
1988 Some(0xCAFE_F00D),
1989 100,
1990 None,
1991 )];
1992 let conns = [device(
1993 "USBSTOR\\Disk",
1994 Bus::Usb,
1995 Some(50),
1996 Some(0xCAFE_F00D),
1997 )];
1998 let lnk = LnkSource::new(&links, Some("alice"));
1999 let devices = DeviceSource::new(&conns);
2000 let timeline = build_timeline(&[&lnk, &devices]);
2001 let findings = audit(&timeline);
2002 let f = findings
2003 .iter()
2004 .find(|f| f.code == "USERACT-FILE-ON-EXTERNAL-DEVICE")
2005 .expect("file-on-external-device must fire when serials match");
2006 assert_eq!(f.severity, Some(Severity::Medium));
2007 assert_eq!(f.category, Category::Threat);
2008 }
2009
2010 #[test]
2013 fn menu_item_record_becomes_menu_selected_activity() {
2014 let records = [segb::menuitem::AppMenuItemRecord {
2015 application: Some("Finder".to_string()),
2016 menu_item: Some("Move to Trash".to_string()),
2017 timestamp_unix: Some(1_700_000_000.0_f64),
2018 }];
2019 let acts = from_biome_menu_items(&records, Some("alice"));
2020 assert_eq!(acts.len(), 1);
2021 let a = &acts[0];
2022 assert_eq!(a.action, Action::MenuSelected);
2023 assert_eq!(a.source, SourceKind::BiomeMenuItem);
2024 assert_eq!(a.timestamp, Some(1_700_000_000_i64));
2025 assert_eq!(a.actor.as_deref(), Some("alice"));
2026 assert_eq!(a.subject, Subject::Command("Finder: Move to Trash".to_string()));
2027 assert_eq!(a.detail, "Finder: Move to Trash");
2028 }
2029
2030 #[test]
2031 fn menu_item_record_no_actor() {
2032 let records = [segb::menuitem::AppMenuItemRecord {
2033 application: Some("TextEdit".to_string()),
2034 menu_item: Some("Save\u{2026}".to_string()),
2035 timestamp_unix: Some(1_700_000_100.0_f64),
2036 }];
2037 let acts = from_biome_menu_items(&records, None);
2038 assert_eq!(acts.len(), 1);
2039 assert_eq!(acts[0].actor, None);
2040 assert_eq!(acts[0].detail, "TextEdit: Save\u{2026}");
2041 assert_eq!(
2042 acts[0].subject,
2043 Subject::Command("TextEdit: Save\u{2026}".to_string())
2044 );
2045 }
2046
2047 #[test]
2048 fn menu_item_record_with_no_menu_item_is_skipped() {
2049 let records = [segb::menuitem::AppMenuItemRecord {
2051 application: Some("Finder".to_string()),
2052 menu_item: None,
2053 timestamp_unix: Some(1_700_000_200.0_f64),
2054 }];
2055 let acts = from_biome_menu_items(&records, None);
2056 assert!(acts.is_empty(), "records with no menu_item must be skipped");
2057 }
2058
2059 #[test]
2060 fn menu_item_record_with_no_timestamp_yields_none_timestamp() {
2061 let records = [segb::menuitem::AppMenuItemRecord {
2062 application: Some("Safari".to_string()),
2063 menu_item: Some("Open in New Tab".to_string()),
2064 timestamp_unix: None,
2065 }];
2066 let acts = from_biome_menu_items(&records, None);
2067 assert_eq!(acts.len(), 1);
2068 assert_eq!(acts[0].timestamp, None);
2069 }
2070
2071 #[test]
2072 fn biome_menu_item_source_adapter_dispatches() {
2073 let records = [segb::menuitem::AppMenuItemRecord {
2074 application: Some("Mail".to_string()),
2075 menu_item: Some("Reply".to_string()),
2076 timestamp_unix: Some(1_700_001_000.0_f64),
2077 }];
2078 let src = BiomeMenuItemSource::new(&records, None);
2079 let acts = src.activities();
2080 assert_eq!(acts.len(), 1);
2081 assert_eq!(acts[0].source, SourceKind::BiomeMenuItem);
2082 }
2083}