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(records: &'a [segb::menuitem::AppMenuItemRecord], actor: Option<&str>) -> Self {
761 Self {
762 records,
763 actor: actor.map(ToString::to_string),
764 }
765 }
766}
767
768impl ActivitySource for BiomeMenuItemSource<'_> {
769 fn activities(&self) -> Vec<UserActivity> {
770 from_biome_menu_items(self.records, self.actor.as_deref())
771 }
772}
773
774#[must_use]
789pub fn from_biome_menu_items(
790 records: &[segb::menuitem::AppMenuItemRecord],
791 actor: Option<&str>,
792) -> Vec<UserActivity> {
793 records
794 .iter()
795 .filter_map(|r| {
796 let menu_item = r.menu_item.as_deref()?;
797 let label = match r.application.as_deref() {
798 Some(app) => format!("{app}: {menu_item}"),
799 None => menu_item.to_string(),
800 };
801 let timestamp = r
802 .timestamp_unix
803 .and_then(|t| t.is_finite().then_some(t as i64));
804 Some(UserActivity {
805 timestamp,
806 actor: actor.map(ToString::to_string),
807 action: Action::MenuSelected,
808 subject: Subject::Command(label.clone()),
809 source: SourceKind::BiomeMenuItem,
810 detail: label,
811 })
812 })
813 .collect()
814}
815
816#[must_use]
822pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
823 let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
824 events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
827 events
828}
829
830pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
836
837pub const NETWORK_EXFIL_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
846
847#[must_use]
849pub fn source(scope: impl Into<String>) -> Source {
850 Source {
851 analyzer: "useract-forensic".to_string(),
852 scope: scope.into(),
853 version: Some(env!("CARGO_PKG_VERSION").to_string()),
854 }
855}
856
857#[must_use]
870pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
871 let mut pairs = Vec::new();
872 for (di, dev) in events.iter().enumerate() {
873 let Subject::Device {
874 volume_serial: Some(dev_serial),
875 ..
876 } = &dev.subject
877 else {
878 continue;
879 };
880 for (fi, file) in events.iter().enumerate() {
881 if file_volume_serial(file) == Some(*dev_serial) {
882 pairs.push((di, fi));
883 }
884 }
885 }
886 pairs
887}
888
889fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
893 let structured = match &activity.subject {
894 Subject::File { volume_serial, .. } | Subject::Folder { volume_serial, .. } => {
895 *volume_serial
896 }
897 _ => return None,
898 };
899 if structured.is_some() {
900 return structured;
901 }
902 for tok in activity.detail.split_whitespace() {
903 if let Some(rest) = tok.strip_prefix("vol:") {
904 if let Ok(serial) = rest.parse::<u32>() {
905 return Some(serial);
906 }
907 }
908 }
909 None
910}
911
912#[must_use]
925pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
926 audit_with(events, &source("host"))
927}
928
929#[must_use]
931pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
932 let mut findings = Vec::new();
933
934 let media_windows: Vec<(i64, &str)> = events
942 .iter()
943 .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
944 (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
945 Some((ts, id.as_str()))
946 }
947 _ => None,
948 })
949 .collect();
950
951 for (di, fi) in device_file_volume_joins(events) {
954 findings.push(file_on_external_device_finding(
955 &events[di],
956 &events[fi],
957 src,
958 ));
959 }
960
961 for event in events {
962 if event.action == Action::HistoryTampered {
964 findings.push(history_tampered_finding(event, src));
965 continue;
966 }
967
968 if event.source == SourceKind::Srum {
971 if let Some(bytes_sent) = srum_network_bytes_sent(event) {
972 if bytes_sent >= NETWORK_EXFIL_BYTES_THRESHOLD {
973 findings.push(network_exfil_volume_finding(event, bytes_sent, src));
974 }
975 }
976 }
977
978 if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
980 (event.action, event.timestamp, &event.subject)
981 {
982 if let Some((win_ts, dev_id)) = media_windows
983 .iter()
984 .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
985 {
986 findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
987 }
988 }
989 }
990
991 findings
992}
993
994fn is_mass_storage_id(instance_id: &str) -> bool {
1000 let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
1001 Bus::from_enumerator(enumerator).is_mass_storage()
1002}
1003
1004fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
1005 let cmd = match &event.subject {
1006 Subject::Command(c) => c.as_str(),
1007 _ => event.detail.as_str(),
1008 };
1009 Finding::observation(
1010 Severity::Medium,
1011 Category::Concealment,
1012 "USERACT-HISTORY-TAMPERED",
1013 )
1014 .source(src.clone())
1015 .note(format!(
1016 "user activity {cmd:?} disables or clears the activity record; consistent with \
1017 anti-forensic history tampering (MITRE T1070.003)"
1018 ))
1019 .evidence("command", cmd.to_string())
1020 .external_ref(ExternalRef::mitre_attack("T1070.003"))
1021 .build()
1022}
1023
1024fn exec_during_media_finding(
1025 cmd: &str,
1026 cmd_ts: i64,
1027 dev_ts: i64,
1028 dev_id: &str,
1029 src: &Source,
1030) -> Finding {
1031 Finding::observation(
1032 Severity::Low,
1033 Category::Threat,
1034 "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
1035 )
1036 .source(src.clone())
1037 .note(format!(
1038 "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
1039 device {dev_id:?} being connected; consistent with activity involving external media \
1040 (MITRE T1052 / T1091)"
1041 ))
1042 .evidence("command", cmd.to_string())
1043 .evidence("device", dev_id.to_string())
1044 .evidence("command_epoch", cmd_ts.to_string())
1045 .evidence("device_epoch", dev_ts.to_string())
1046 .external_ref(ExternalRef::mitre_attack("T1052"))
1047 .external_ref(ExternalRef::mitre_attack("T1091"))
1048 .build()
1049}
1050
1051fn srum_network_bytes_sent(activity: &UserActivity) -> Option<u64> {
1055 let prefix = activity.detail.split('\u{2191}').next()?;
1056 prefix.trim().parse::<u64>().ok()
1057}
1058
1059fn network_exfil_volume_finding(event: &UserActivity, bytes_sent: u64, src: &Source) -> Finding {
1060 let app = match &event.subject {
1061 Subject::Command(c) => c.as_str(),
1062 _ => event.detail.as_str(), };
1064 let actor = event.actor.as_deref().unwrap_or("(unattributed)");
1065 Finding::observation(
1066 Severity::Medium,
1067 Category::Threat,
1068 "USERACT-NETWORK-EXFIL-VOLUME",
1069 )
1070 .source(src.clone())
1071 .note(format!(
1072 "SRUM records {bytes_sent} bytes sent in one interval by {app:?} attributed to user \
1073 {actor:?}; the volume exceeds the {NETWORK_EXFIL_BYTES_THRESHOLD}-byte lead threshold and \
1074 is consistent with bulk data exfiltration (MITRE T1048 / T1052) — a graded lead for the \
1075 examiner, not a verdict"
1076 ))
1077 .evidence("application", app.to_string())
1078 .evidence("actor", actor.to_string())
1079 .evidence("bytes_sent", bytes_sent.to_string())
1080 .external_ref(ExternalRef::mitre_attack("T1048"))
1081 .external_ref(ExternalRef::mitre_attack("T1052"))
1082 .build()
1083}
1084
1085fn file_on_external_device_finding(
1086 device: &UserActivity,
1087 file: &UserActivity,
1088 src: &Source,
1089) -> Finding {
1090 let path = match &file.subject {
1091 Subject::File { path, .. } | Subject::Folder { path, .. } => path.as_str(),
1092 _ => file.detail.as_str(), };
1094 let dev_id = match &device.subject {
1095 Subject::Device { id, .. } => id.as_str(),
1096 _ => device.detail.as_str(), };
1098 let serial = match &device.subject {
1099 Subject::Device {
1100 volume_serial: Some(s),
1101 ..
1102 } => *s,
1103 _ => 0, };
1105 Finding::observation(
1106 Severity::Medium,
1107 Category::Threat,
1108 "USERACT-FILE-ON-EXTERNAL-DEVICE",
1109 )
1110 .source(src.clone())
1111 .note(format!(
1112 "a user accessed {path:?} on a volume (serial {serial:#010x}) whose serial matches the \
1113 connected external device {dev_id:?}; consistent with data movement to/from removable \
1114 media (MITRE T1052 / T1091)"
1115 ))
1116 .evidence("file", path.to_string())
1117 .evidence("device", dev_id.to_string())
1118 .evidence("volume_serial", format!("{serial:#010x}"))
1119 .external_ref(ExternalRef::mitre_attack("T1052"))
1120 .external_ref(ExternalRef::mitre_attack("T1091"))
1121 .build()
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127 use peripheral_core::{Bus, Provenance, Stamp};
1128 use shellhist_core::{HistoryEntry, Shell};
1129
1130 fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
1131 HistoryEntry {
1132 shell: Shell::Bash,
1133 command: cmd.to_string(),
1134 timestamp: ts,
1135 elapsed: None,
1136 paths: Vec::new(),
1137 }
1138 }
1139
1140 fn device(
1141 instance_id: &str,
1142 bus: Bus,
1143 first_install: Option<i64>,
1144 vol: Option<u32>,
1145 ) -> DeviceConnection {
1146 DeviceConnection {
1147 bus,
1148 device_class_guid: None,
1149 vid: None,
1150 pid: None,
1151 device_serial: None,
1152 serial_is_os_generated: false,
1153 friendly_name: None,
1154 device_instance_id: instance_id.to_string(),
1155 first_install: first_install.map(Stamp::authoritative),
1156 last_install: None,
1157 last_arrival: None,
1158 last_removal: None,
1159 parent_id_prefix: None,
1160 volume_guid: None,
1161 drive_letter: None,
1162 volume_serial: vol,
1163 disk_signature: None,
1164 dma_capable: bus.is_dma_capable(),
1165 mitre: Vec::new(),
1166 source: Provenance {
1167 file: "setupapi.dev.log".to_string(),
1168 line: 1,
1169 },
1170 }
1171 }
1172
1173 #[test]
1176 fn shell_command_becomes_executed_activity() {
1177 let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
1178 let acts = from_shell_history(&entries, None);
1179 assert_eq!(acts.len(), 1);
1180 assert_eq!(acts[0].action, Action::Executed);
1181 assert_eq!(acts[0].source, SourceKind::ShellHistory);
1182 assert_eq!(acts[0].timestamp, Some(1_700_000_000));
1183 assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
1184 assert_eq!(acts[0].actor, None);
1185 }
1186
1187 #[test]
1188 fn shell_actor_is_carried_when_known() {
1189 let entries = [entry("whoami", None)];
1190 let acts = from_shell_history(&entries, Some("alice"));
1191 assert_eq!(acts[0].actor.as_deref(), Some("alice"));
1192 }
1193
1194 #[test]
1195 fn history_clearing_command_becomes_tampered() {
1196 for cmd in [
1197 "unset HISTFILE",
1198 "history -c",
1199 "export HISTFILE=/dev/null",
1200 "Clear-History",
1201 "rm ~/.bash_history",
1202 ] {
1203 let entries = [entry(cmd, Some(1))];
1204 let acts = from_shell_history(&entries, None);
1205 assert_eq!(acts[0].action, Action::HistoryTampered);
1206 }
1207 }
1208
1209 #[test]
1210 fn benign_command_is_not_tampered() {
1211 let entries = [entry("git log --oneline", Some(1))];
1212 let acts = from_shell_history(&entries, None);
1213 assert_eq!(acts[0].action, Action::Executed);
1214 }
1215
1216 #[test]
1219 fn device_becomes_connected_with_volume_serial() {
1220 let conns = [device(
1221 "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
1222 Bus::Usb,
1223 Some(1_700_000_500),
1224 Some(0xDEAD_BEEF),
1225 )];
1226 let acts = from_device_connections(&conns);
1227 assert_eq!(acts.len(), 1);
1228 assert_eq!(acts[0].action, Action::Connected);
1229 assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
1230 assert_eq!(acts[0].timestamp, Some(1_700_000_500));
1231 assert_eq!(
1232 acts[0].subject,
1233 Subject::Device {
1234 id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
1235 volume_serial: Some(0xDEAD_BEEF),
1236 }
1237 );
1238 }
1239
1240 #[test]
1241 fn device_timestamp_falls_back_through_stamps() {
1242 let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
1243 conn.last_arrival = Some(Stamp::inferred(42));
1244 let acts = from_device_connections(&[conn]);
1245 assert_eq!(acts[0].timestamp, Some(42));
1246 }
1247
1248 #[test]
1249 fn device_without_any_stamp_has_no_timestamp() {
1250 let conn = device("USB\\VID_0781", Bus::Usb, None, None);
1251 let acts = from_device_connections(&[conn]);
1252 assert_eq!(acts[0].timestamp, None);
1253 }
1254
1255 #[test]
1258 fn timeline_merges_and_sorts_by_timestamp() {
1259 let entries = [entry("late", Some(300)), entry("early", Some(100))];
1260 let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
1261 let shell = ShellHistorySource::new(&entries);
1262 let devices = DeviceSource::new(&conns);
1263 let tl = build_timeline(&[&shell, &devices]);
1264 let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
1265 assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
1266 }
1267
1268 #[test]
1269 fn timeline_orders_untimestamped_events_last_and_stably() {
1270 let entries = [
1271 entry("no_ts_a", None),
1272 entry("ts", Some(50)),
1273 entry("no_ts_b", None),
1274 ];
1275 let shell = ShellHistorySource::new(&entries);
1276 let tl = build_timeline(&[&shell]);
1277 assert_eq!(tl[0].timestamp, Some(50));
1278 assert_eq!(tl[1].detail, "no_ts_a");
1279 assert_eq!(tl[2].detail, "no_ts_b");
1280 }
1281
1282 #[test]
1285 fn audit_surfaces_history_tampered() {
1286 let entries = [entry("unset HISTFILE", Some(10))];
1287 let acts = from_shell_history(&entries, None);
1288 let findings = audit(&acts);
1289 let f = findings
1290 .iter()
1291 .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
1292 .expect("history-tampered finding must fire");
1293 assert_eq!(f.severity, Some(Severity::Medium));
1294 assert_eq!(f.category, Category::Concealment);
1295 }
1296
1297 #[test]
1300 fn audit_fires_exec_during_removable_media_within_window() {
1301 let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
1302 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
1303 let shell = ShellHistorySource::new(&entries);
1304 let devices = DeviceSource::new(&conns);
1305 let tl = build_timeline(&[&shell, &devices]);
1306 let findings = audit(&tl);
1307 assert!(findings
1308 .iter()
1309 .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1310 }
1311
1312 #[test]
1313 fn audit_does_not_fire_outside_window() {
1314 let entries = [entry("ls", Some(1_000))];
1315 let conns = [device(
1316 "USBSTOR\\Disk",
1317 Bus::Usb,
1318 Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
1319 None,
1320 )];
1321 let shell = ShellHistorySource::new(&entries);
1322 let devices = DeviceSource::new(&conns);
1323 let tl = build_timeline(&[&shell, &devices]);
1324 let findings = audit(&tl);
1325 assert!(findings
1326 .iter()
1327 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1328 }
1329
1330 #[test]
1331 fn audit_does_not_fire_for_non_mass_storage_device() {
1332 let entries = [entry("ls", Some(1_000))];
1334 let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
1335 let shell = ShellHistorySource::new(&entries);
1336 let devices = DeviceSource::new(&conns);
1337 let tl = build_timeline(&[&shell, &devices]);
1338 let findings = audit(&tl);
1339 assert!(findings
1340 .iter()
1341 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1342 }
1343
1344 #[test]
1345 fn audit_with_custom_source_stamps_scope() {
1346 let entries = [entry("history -c", Some(1))];
1347 let acts = from_shell_history(&entries, None);
1348 let findings = audit_with(&acts, &source("CASE-001/host-7"));
1349 let f = &findings[0];
1350 assert_eq!(f.source.scope, "CASE-001/host-7");
1351 assert_eq!(f.source.analyzer, "useract-forensic");
1352 }
1353
1354 #[test]
1357 fn findings_are_hedged_observations_never_verdicts() {
1358 let entries = [
1359 entry("unset HISTFILE", Some(1_000)),
1360 entry("cp x /media/usb", Some(1_010)),
1361 ];
1362 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
1363 let shell = ShellHistorySource::new(&entries);
1364 let devices = DeviceSource::new(&conns);
1365 let tl = build_timeline(&[&shell, &devices]);
1366 let findings = audit(&tl);
1367 assert!(!findings.is_empty());
1368 for f in &findings {
1369 let note = f.note.to_ascii_lowercase();
1370 assert!(!note.contains("proves"));
1371 assert!(!note.contains("confirms"));
1372 assert!(!note.contains("definitely"));
1373 assert!(note.contains("consistent with"));
1374 }
1375 }
1376
1377 #[test]
1380 fn volume_serial_join_is_empty_for_v01_sources() {
1381 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1383 let acts = from_device_connections(&conns);
1384 assert!(device_file_volume_joins(&acts).is_empty());
1385 }
1386
1387 #[test]
1388 fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
1389 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1392 let mut acts = from_device_connections(&conns);
1393 acts.push(UserActivity {
1394 timestamp: Some(2),
1395 actor: None,
1396 action: Action::Accessed,
1397 subject: Subject::file("\\\\?\\E:\\secret.docx"),
1398 source: SourceKind::PeripheralDevice, detail: "opened E:\\secret.docx vol:4660".to_string(), });
1401 let joins = device_file_volume_joins(&acts);
1402 assert_eq!(joins, vec![(0, 1)]);
1403 }
1404
1405 #[test]
1406 fn volume_serial_join_ignores_mismatched_serials() {
1407 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1408 let mut acts = from_device_connections(&conns);
1409 acts.push(UserActivity {
1410 timestamp: Some(2),
1411 actor: None,
1412 action: Action::Accessed,
1413 subject: Subject::file("x"),
1414 source: SourceKind::PeripheralDevice,
1415 detail: "vol:9999".to_string(),
1416 });
1417 assert!(device_file_volume_joins(&acts).is_empty());
1418 }
1419
1420 #[test]
1421 fn volume_serial_join_skips_files_without_a_volume_token() {
1422 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1425 let mut acts = from_device_connections(&conns);
1426 acts.push(UserActivity {
1427 timestamp: Some(2),
1428 actor: None,
1429 action: Action::Accessed,
1430 subject: Subject::folder("E:\\photos"),
1431 source: SourceKind::PeripheralDevice,
1432 detail: "opened folder with no serial hint".to_string(),
1433 });
1434 acts.push(UserActivity {
1436 timestamp: Some(3),
1437 actor: None,
1438 action: Action::Accessed,
1439 subject: Subject::file("E:\\x"),
1440 source: SourceKind::PeripheralDevice,
1441 detail: "vol:notanumber".to_string(),
1442 });
1443 assert!(device_file_volume_joins(&acts).is_empty());
1444 }
1445
1446 #[test]
1447 fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
1448 let act = UserActivity {
1451 timestamp: Some(1),
1452 actor: None,
1453 action: Action::HistoryTampered,
1454 subject: Subject::file("ConsoleHost_history.txt"),
1455 source: SourceKind::ShellHistory,
1456 detail: "Remove-Item ConsoleHost_history.txt".to_string(),
1457 };
1458 let findings = audit(&[act]);
1459 assert_eq!(findings.len(), 1);
1460 assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
1461 assert!(findings[0]
1462 .note
1463 .contains("Remove-Item ConsoleHost_history.txt"));
1464 }
1465
1466 #[test]
1467 fn is_mass_storage_id_classifies_bare_and_separated_ids() {
1468 assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
1469 assert!(is_mass_storage_id("USBSTOR"));
1470 assert!(!is_mass_storage_id("BTHENUM\\Dev"));
1471 assert!(!is_mass_storage_id(""));
1472 }
1473
1474 #[test]
1475 fn activitysource_trait_dispatches() {
1476 let entries = [entry("ls", Some(1))];
1477 let s = ShellHistorySource::for_actor(&entries, "bob");
1478 let acts: Vec<UserActivity> = s.activities();
1479 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1480 }
1481
1482 use srum_core::{AppUsageRecord, IdMapEntry, NetworkUsageRecord};
1485
1486 fn utc(epoch: i64) -> chrono::DateTime<chrono::Utc> {
1487 chrono::DateTime::from_timestamp(epoch, 0).expect("valid epoch")
1488 }
1489
1490 #[test]
1491 fn srum_network_row_is_executed_and_actor_attributed() {
1492 let id_map = [
1494 IdMapEntry {
1495 id: 7,
1496 name: "S-1-5-21-1-2-3-1001".to_string(),
1497 },
1498 IdMapEntry {
1499 id: 42,
1500 name: "\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string(),
1501 },
1502 ];
1503 let net = [NetworkUsageRecord {
1504 app_id: 42,
1505 user_id: 7,
1506 timestamp: utc(1_700_000_000),
1507 bytes_sent: 4096,
1508 bytes_recv: 1024,
1509 auto_inc_id: 0,
1510 }];
1511 let acts = from_srum(&net, &[], &id_map);
1512 assert_eq!(acts.len(), 1);
1513 let a = &acts[0];
1514 assert_eq!(a.action, Action::Executed);
1515 assert_eq!(a.source, SourceKind::Srum);
1516 assert_eq!(a.timestamp, Some(1_700_000_000));
1517 assert_eq!(a.actor.as_deref(), Some("S-1-5-21-1-2-3-1001"));
1519 assert_eq!(
1521 a.subject,
1522 Subject::Command("\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string())
1523 );
1524 assert!(a.detail.contains("4096"));
1526 assert!(a.detail.contains("1024"));
1527 }
1528
1529 #[test]
1530 fn srum_unresolved_user_id_falls_back_to_numeric_token() {
1531 let net = [NetworkUsageRecord {
1533 app_id: 1,
1534 user_id: 99,
1535 timestamp: utc(10),
1536 bytes_sent: 1,
1537 bytes_recv: 2,
1538 auto_inc_id: 0,
1539 }];
1540 let acts = from_srum(&net, &[], &[]);
1541 assert_eq!(acts.len(), 1);
1542 assert_eq!(acts[0].actor.as_deref(), Some("user-id:99"));
1543 assert_eq!(acts[0].subject, Subject::Command("app-id:1".to_string()));
1545 }
1546
1547 #[test]
1548 fn srum_app_usage_row_is_executed_and_actor_attributed() {
1549 let id_map = [
1550 IdMapEntry {
1551 id: 5,
1552 name: "S-1-5-21-9-9-9-500".to_string(),
1553 },
1554 IdMapEntry {
1555 id: 8,
1556 name: "C:\\Tools\\rclone.exe".to_string(),
1557 },
1558 ];
1559 let app = [AppUsageRecord {
1560 app_id: 8,
1561 user_id: 5,
1562 timestamp: utc(1_700_000_500),
1563 foreground_cycles: 900_000,
1564 background_cycles: 100,
1565 auto_inc_id: 0,
1566 }];
1567 let acts = from_srum(&[], &app, &id_map);
1568 assert_eq!(acts.len(), 1);
1569 assert_eq!(acts[0].action, Action::Executed);
1570 assert_eq!(acts[0].source, SourceKind::Srum);
1571 assert_eq!(acts[0].actor.as_deref(), Some("S-1-5-21-9-9-9-500"));
1572 assert_eq!(
1573 acts[0].subject,
1574 Subject::Command("C:\\Tools\\rclone.exe".to_string())
1575 );
1576 }
1577
1578 #[test]
1579 fn srum_source_adapter_dispatches() {
1580 let net = [NetworkUsageRecord {
1581 app_id: 1,
1582 user_id: 1,
1583 timestamp: utc(1),
1584 bytes_sent: 1,
1585 bytes_recv: 1,
1586 auto_inc_id: 0,
1587 }];
1588 let s = SrumSource::new(&net, &[], &[]);
1589 let acts = s.activities();
1590 assert_eq!(acts.len(), 1);
1591 assert_eq!(acts[0].source, SourceKind::Srum);
1592 }
1593
1594 #[test]
1597 fn audit_fires_network_exfil_volume_above_threshold() {
1598 let id_map = [
1599 IdMapEntry {
1600 id: 7,
1601 name: "S-1-5-21-1-2-3-1001".to_string(),
1602 },
1603 IdMapEntry {
1604 id: 42,
1605 name: "rclone.exe".to_string(),
1606 },
1607 ];
1608 let net = [NetworkUsageRecord {
1609 app_id: 42,
1610 user_id: 7,
1611 timestamp: utc(1_700_000_000),
1612 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD + 1,
1613 bytes_recv: 0,
1614 auto_inc_id: 0,
1615 }];
1616 let acts = from_srum(&net, &[], &id_map);
1617 let findings = audit(&acts);
1618 let f = findings
1619 .iter()
1620 .find(|f| f.code == "USERACT-NETWORK-EXFIL-VOLUME")
1621 .expect("network-exfil-volume must fire above threshold");
1622 assert_eq!(f.severity, Some(Severity::Medium));
1623 assert_eq!(f.category, Category::Threat);
1624 }
1625
1626 #[test]
1627 fn audit_does_not_fire_network_exfil_below_threshold() {
1628 let net = [NetworkUsageRecord {
1629 app_id: 1,
1630 user_id: 1,
1631 timestamp: utc(1),
1632 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD - 1,
1633 bytes_recv: 0,
1634 auto_inc_id: 0,
1635 }];
1636 let acts = from_srum(&net, &[], &[]);
1637 let findings = audit(&acts);
1638 assert!(findings
1639 .iter()
1640 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1641 }
1642
1643 #[test]
1644 fn audit_skips_exfil_check_for_srum_app_usage_rows() {
1645 let app = [AppUsageRecord {
1649 app_id: 1,
1650 user_id: 1,
1651 timestamp: utc(1),
1652 foreground_cycles: u64::MAX,
1653 background_cycles: u64::MAX,
1654 auto_inc_id: 0,
1655 }];
1656 let acts = from_srum(&[], &app, &[]);
1657 let findings = audit(&acts);
1658 assert!(findings
1659 .iter()
1660 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1661 }
1662
1663 use winreg_artifacts::shellbags::ShellbagEntry;
1666 use winreg_artifacts::typed_urls::TypedUrl;
1667 use winreg_artifacts::userassist::UserAssistEntry;
1668
1669 fn ua(program: &str, run_count: u32, last_run: Option<&str>) -> UserAssistEntry {
1670 UserAssistEntry {
1671 program: program.to_string(),
1672 run_count,
1673 focus_count: 0,
1674 focus_duration_ms: 0,
1675 last_run: last_run.map(ToString::to_string),
1676 guid: "{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}".to_string(),
1677 }
1678 }
1679
1680 #[test]
1681 fn userassist_entry_becomes_executed_with_run_count() {
1682 let entries = [ua(
1683 "C:\\Windows\\System32\\cmd.exe",
1684 5,
1685 Some("2024-06-15T08:00:00Z"),
1686 )];
1687 let acts = from_userassist(&entries, Some("alice"));
1688 assert_eq!(acts.len(), 1);
1689 let a = &acts[0];
1690 assert_eq!(a.action, Action::Executed);
1691 assert_eq!(a.source, SourceKind::Registry);
1692 assert_eq!(
1693 a.subject,
1694 Subject::Command("C:\\Windows\\System32\\cmd.exe".to_string())
1695 );
1696 assert_eq!(a.timestamp, Some(1_718_438_400));
1698 assert_eq!(a.actor.as_deref(), Some("alice"));
1699 assert!(a.detail.contains('5'));
1701 }
1702
1703 #[test]
1704 fn userassist_without_last_run_has_no_timestamp() {
1705 let entries = [ua("notepad.exe", 1, None)];
1706 let acts = from_userassist(&entries, None);
1707 assert_eq!(acts[0].timestamp, None);
1708 assert_eq!(acts[0].actor, None);
1709 }
1710
1711 #[test]
1712 fn typed_url_becomes_typed_activity() {
1713 let urls = [TypedUrl {
1714 url: "https://pastebin.com/abc".to_string(),
1715 last_visited: Some("2024-01-02T03:04:05Z".to_string()),
1716 is_suspicious: true,
1717 suspicious_reason: Some("suspicious domain: pastebin.com".to_string()),
1718 }];
1719 let acts = from_typed_urls(&urls, None);
1720 assert_eq!(acts.len(), 1);
1721 assert_eq!(acts[0].action, Action::Typed);
1722 assert_eq!(acts[0].source, SourceKind::Registry);
1723 assert_eq!(
1724 acts[0].subject,
1725 Subject::Query("https://pastebin.com/abc".to_string())
1726 );
1727 assert!(acts[0].timestamp.is_some());
1728 }
1729
1730 #[test]
1731 fn shellbag_becomes_accessed_folder() {
1732 let bags = [ShellbagEntry {
1733 path: "BagMRU[slot=0, size=120 bytes]".to_string(),
1734 key_path: "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0".to_string(),
1735 last_written: Some("2024-03-04T05:06:07Z".to_string()),
1736 mru_order: vec!["0".to_string()],
1737 }];
1738 let acts = from_shellbags(&bags, Some("bob"));
1739 assert_eq!(acts.len(), 1);
1740 assert_eq!(acts[0].action, Action::Accessed);
1741 assert_eq!(acts[0].source, SourceKind::Registry);
1742 assert!(matches!(acts[0].subject, Subject::Folder { .. }));
1743 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1744 }
1745
1746 #[test]
1747 fn from_registry_merges_all_three_registry_artifacts() {
1748 let ua_entries = [ua("cmd.exe", 1, Some("2024-06-15T08:00:00Z"))];
1749 let urls = [TypedUrl {
1750 url: "https://x.test".to_string(),
1751 last_visited: None,
1752 is_suspicious: false,
1753 suspicious_reason: None,
1754 }];
1755 let bags = [ShellbagEntry {
1756 path: "BagMRU[slot=0, size=10 bytes]".to_string(),
1757 key_path: "k".to_string(),
1758 last_written: None,
1759 mru_order: vec![],
1760 }];
1761 let acts = from_registry(&ua_entries, &urls, &bags, Some("alice"));
1762 assert_eq!(acts.len(), 3);
1763 assert!(acts.iter().any(|a| a.action == Action::Executed));
1764 assert!(acts.iter().any(|a| a.action == Action::Typed));
1765 assert!(acts.iter().any(|a| a.action == Action::Accessed));
1766 assert!(acts.iter().all(|a| a.source == SourceKind::Registry));
1767 assert!(acts.iter().all(|a| a.actor.as_deref() == Some("alice")));
1768 }
1769
1770 #[test]
1771 fn registry_source_adapter_dispatches() {
1772 let ua_entries = [ua("cmd.exe", 1, None)];
1773 let s = RegistrySource::new(&ua_entries, &[], &[], None);
1774 let acts = s.activities();
1775 assert_eq!(acts.len(), 1);
1776 assert_eq!(acts[0].source, SourceKind::Registry);
1777 }
1778
1779 use lnk_core::{LinkInfo, ShellLink, ShellLinkHeader, StringData, VolumeId};
1782
1783 fn shell_link(
1784 local_base_path: Option<&str>,
1785 drive_serial: Option<u32>,
1786 write_time: i64,
1787 net_name: Option<&str>,
1788 ) -> ShellLink {
1789 let volume_id = drive_serial.map(|s| VolumeId {
1790 drive_type: lnk_core::drive_type::REMOVABLE,
1791 drive_serial_number: s,
1792 volume_label: None,
1793 });
1794 let cnrl = net_name.map(|n| lnk_core::CommonNetworkRelativeLink {
1795 net_name: Some(n.to_string()),
1796 device_name: None,
1797 });
1798 ShellLink {
1799 header: ShellLinkHeader {
1800 link_flags: 0,
1801 file_attributes: 0,
1802 creation_time: 0,
1803 access_time: 0,
1804 write_time,
1805 file_size: 0,
1806 icon_index: 0,
1807 show_command: 1,
1808 hotkey: 0,
1809 },
1810 link_target_idlist: None,
1811 link_info: Some(LinkInfo {
1812 volume_id,
1813 local_base_path: local_base_path.map(ToString::to_string),
1814 common_network_relative_link: cnrl,
1815 }),
1816 string_data: StringData::default(),
1817 tracker: None,
1818 }
1819 }
1820
1821 #[test]
1822 fn lnk_target_becomes_accessed_file_with_volume_serial() {
1823 let links = [shell_link(
1824 Some("E:\\secret.docx"),
1825 Some(0xDEAD_BEEF),
1826 1_700_000_000,
1827 None,
1828 )];
1829 let acts = from_lnk(&links, Some("alice"));
1830 assert_eq!(acts.len(), 1);
1831 let a = &acts[0];
1832 assert_eq!(a.action, Action::Accessed);
1833 assert_eq!(a.source, SourceKind::LnkFile);
1834 assert_eq!(a.timestamp, Some(1_700_000_000));
1836 assert_eq!(a.actor.as_deref(), Some("alice"));
1837 assert_eq!(
1839 a.subject,
1840 Subject::File {
1841 path: "E:\\secret.docx".to_string(),
1842 volume_serial: Some(0xDEAD_BEEF),
1843 }
1844 );
1845 }
1846
1847 #[test]
1848 fn lnk_without_volume_id_has_no_serial() {
1849 let links = [shell_link(Some("C:\\x.txt"), None, 0, None)];
1850 let acts = from_lnk(&links, None);
1851 assert_eq!(acts.len(), 1);
1852 assert_eq!(
1853 acts[0].subject,
1854 Subject::File {
1855 path: "C:\\x.txt".to_string(),
1856 volume_serial: None,
1857 }
1858 );
1859 assert_eq!(acts[0].timestamp, None);
1861 }
1862
1863 #[test]
1864 fn lnk_network_target_falls_back_to_unc_path() {
1865 let links = [shell_link(None, None, 5, Some("\\\\server\\share"))];
1867 let acts = from_lnk(&links, None);
1868 assert_eq!(acts.len(), 1);
1869 assert_eq!(
1870 acts[0].subject,
1871 Subject::File {
1872 path: "\\\\server\\share".to_string(),
1873 volume_serial: None,
1874 }
1875 );
1876 }
1877
1878 #[test]
1879 fn lnk_without_link_info_is_skipped() {
1880 let mut link = shell_link(None, None, 0, None);
1882 link.link_info = None;
1883 let acts = from_lnk(&[link], None);
1884 assert!(acts.is_empty());
1885 }
1886
1887 fn destlist(path: &str, host: &str, last_access: i64) -> lnk_core::DestListEntry {
1888 lnk_core::DestListEntry {
1889 droid_volume_guid: String::new(),
1890 droid_file_guid: String::new(),
1891 birth_droid_volume_guid: String::new(),
1892 birth_droid_file_guid: String::new(),
1893 hostname: host.to_string(),
1894 entry_number: 1,
1895 last_access,
1896 pinned: false,
1897 access_count: Some(3),
1898 path: path.to_string(),
1899 }
1900 }
1901
1902 #[test]
1903 fn jumplist_automatic_entry_becomes_accessed_file() {
1904 let link = shell_link(
1908 Some("C:\\Users\\bob\\q3.xlsx"),
1909 Some(0x1234_5678),
1910 1_700_000_000,
1911 None,
1912 );
1913 let lists = [lnk_core::JumpList {
1914 kind: lnk_core::JumpListKind::Automatic,
1915 app_id: Some("1b4dd67f29cb1962".to_string()),
1916 entries: vec![lnk_core::JumpListEntry {
1917 destlist: Some(destlist("C:\\Users\\bob\\q3.xlsx", "WS01", 1_700_000_500)),
1918 link,
1919 }],
1920 }];
1921 let acts = from_jumplists(&lists, Some("bob"));
1922 assert_eq!(acts.len(), 1);
1923 let a = &acts[0];
1924 assert_eq!(a.action, Action::Accessed);
1925 assert_eq!(a.source, SourceKind::JumpList);
1926 assert_eq!(a.timestamp, Some(1_700_000_500));
1929 assert_eq!(a.actor.as_deref(), Some("bob"));
1930 assert_eq!(
1931 a.subject,
1932 Subject::File {
1933 path: "C:\\Users\\bob\\q3.xlsx".to_string(),
1934 volume_serial: Some(0x1234_5678),
1935 }
1936 );
1937 }
1938
1939 #[test]
1940 fn jumplist_custom_entry_falls_back_to_embedded_link() {
1941 let link = shell_link(
1944 Some("D:\\report.pdf"),
1945 Some(0xAABB_CCDD),
1946 1_690_000_000,
1947 None,
1948 );
1949 let lists = [lnk_core::JumpList {
1950 kind: lnk_core::JumpListKind::Custom,
1951 app_id: None,
1952 entries: vec![lnk_core::JumpListEntry {
1953 destlist: None,
1954 link,
1955 }],
1956 }];
1957 let acts = from_jumplists(&lists, None);
1958 assert_eq!(acts.len(), 1);
1959 assert_eq!(acts[0].source, SourceKind::JumpList);
1960 assert_eq!(acts[0].timestamp, Some(1_690_000_000));
1961 assert_eq!(
1962 acts[0].subject,
1963 Subject::File {
1964 path: "D:\\report.pdf".to_string(),
1965 volume_serial: Some(0xAABB_CCDD),
1966 }
1967 );
1968 }
1969
1970 #[test]
1971 fn jumplist_network_target_falls_back_to_unc_path() {
1972 let link = shell_link(None, None, 1_695_000_000, Some("\\\\nas\\team\\plan.docx"));
1975 let lists = [lnk_core::JumpList {
1976 kind: lnk_core::JumpListKind::Custom,
1977 app_id: None,
1978 entries: vec![lnk_core::JumpListEntry {
1979 destlist: None,
1980 link,
1981 }],
1982 }];
1983 let acts = from_jumplists(&lists, None);
1984 assert_eq!(acts.len(), 1);
1985 assert_eq!(
1986 acts[0].subject,
1987 Subject::File {
1988 path: "\\\\nas\\team\\plan.docx".to_string(),
1989 volume_serial: None,
1990 }
1991 );
1992 }
1993
1994 #[test]
1995 fn jumplist_source_adapter_dispatches() {
1996 let link = shell_link(Some("C:\\x.docx"), Some(7), 1_700_000_000, None);
1997 let lists = [lnk_core::JumpList {
1998 kind: lnk_core::JumpListKind::Automatic,
1999 app_id: Some("1b4dd67f29cb1962".to_string()),
2000 entries: vec![lnk_core::JumpListEntry {
2001 destlist: Some(destlist("C:\\x.docx", "WS01", 1_700_000_500)),
2002 link,
2003 }],
2004 }];
2005 let s = JumpListSource::new(&lists, Some("bob"));
2006 let acts = s.activities();
2007 assert_eq!(acts.len(), 1);
2008 assert_eq!(acts[0].source, SourceKind::JumpList);
2009 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
2010 }
2011
2012 #[test]
2013 fn lnk_source_adapter_dispatches() {
2014 let links = [shell_link(Some("E:\\f"), Some(1), 1, None)];
2015 let s = LnkSource::new(&links, None);
2016 let acts = s.activities();
2017 assert_eq!(acts.len(), 1);
2018 assert_eq!(acts[0].source, SourceKind::LnkFile);
2019 }
2020
2021 #[test]
2024 fn lnk_file_joins_connected_device_on_volume_serial() {
2025 let links = [shell_link(
2026 Some("E:\\loot.zip"),
2027 Some(0xCAFE_F00D),
2028 100,
2029 None,
2030 )];
2031 let conns = [device(
2032 "USBSTOR\\Disk",
2033 Bus::Usb,
2034 Some(50),
2035 Some(0xCAFE_F00D),
2036 )];
2037 let lnk = LnkSource::new(&links, Some("alice"));
2038 let devices = DeviceSource::new(&conns);
2039 let timeline = build_timeline(&[&lnk, &devices]);
2040 let findings = audit(&timeline);
2041 let f = findings
2042 .iter()
2043 .find(|f| f.code == "USERACT-FILE-ON-EXTERNAL-DEVICE")
2044 .expect("file-on-external-device must fire when serials match");
2045 assert_eq!(f.severity, Some(Severity::Medium));
2046 assert_eq!(f.category, Category::Threat);
2047 }
2048
2049 #[test]
2052 fn menu_item_record_becomes_menu_selected_activity() {
2053 let records = [segb::menuitem::AppMenuItemRecord {
2054 application: Some("Finder".to_string()),
2055 menu_item: Some("Move to Trash".to_string()),
2056 timestamp_unix: Some(1_700_000_000.0_f64),
2057 }];
2058 let acts = from_biome_menu_items(&records, Some("alice"));
2059 assert_eq!(acts.len(), 1);
2060 let a = &acts[0];
2061 assert_eq!(a.action, Action::MenuSelected);
2062 assert_eq!(a.source, SourceKind::BiomeMenuItem);
2063 assert_eq!(a.timestamp, Some(1_700_000_000_i64));
2064 assert_eq!(a.actor.as_deref(), Some("alice"));
2065 assert_eq!(
2066 a.subject,
2067 Subject::Command("Finder: Move to Trash".to_string())
2068 );
2069 assert_eq!(a.detail, "Finder: Move to Trash");
2070 }
2071
2072 #[test]
2073 fn menu_item_record_no_actor() {
2074 let records = [segb::menuitem::AppMenuItemRecord {
2075 application: Some("TextEdit".to_string()),
2076 menu_item: Some("Save\u{2026}".to_string()),
2077 timestamp_unix: Some(1_700_000_100.0_f64),
2078 }];
2079 let acts = from_biome_menu_items(&records, None);
2080 assert_eq!(acts.len(), 1);
2081 assert_eq!(acts[0].actor, None);
2082 assert_eq!(acts[0].detail, "TextEdit: Save\u{2026}");
2083 assert_eq!(
2084 acts[0].subject,
2085 Subject::Command("TextEdit: Save\u{2026}".to_string())
2086 );
2087 }
2088
2089 #[test]
2090 fn menu_item_record_without_application_uses_bare_menu_item() {
2091 let records = [segb::menuitem::AppMenuItemRecord {
2093 application: None,
2094 menu_item: Some("Empty Trash".to_string()),
2095 timestamp_unix: Some(1_700_000_300.0_f64),
2096 }];
2097 let acts = from_biome_menu_items(&records, None);
2098 assert_eq!(acts.len(), 1);
2099 assert_eq!(acts[0].detail, "Empty Trash");
2100 assert_eq!(acts[0].subject, Subject::Command("Empty Trash".to_string()));
2101 }
2102
2103 #[test]
2104 fn menu_item_record_with_no_menu_item_is_skipped() {
2105 let records = [segb::menuitem::AppMenuItemRecord {
2107 application: Some("Finder".to_string()),
2108 menu_item: None,
2109 timestamp_unix: Some(1_700_000_200.0_f64),
2110 }];
2111 let acts = from_biome_menu_items(&records, None);
2112 assert!(acts.is_empty(), "records with no menu_item must be skipped");
2113 }
2114
2115 #[test]
2116 fn menu_item_record_with_no_timestamp_yields_none_timestamp() {
2117 let records = [segb::menuitem::AppMenuItemRecord {
2118 application: Some("Safari".to_string()),
2119 menu_item: Some("Open in New Tab".to_string()),
2120 timestamp_unix: None,
2121 }];
2122 let acts = from_biome_menu_items(&records, None);
2123 assert_eq!(acts.len(), 1);
2124 assert_eq!(acts[0].timestamp, None);
2125 }
2126
2127 #[test]
2128 fn biome_menu_item_source_adapter_dispatches() {
2129 let records = [segb::menuitem::AppMenuItemRecord {
2130 application: Some("Mail".to_string()),
2131 menu_item: Some("Reply".to_string()),
2132 timestamp_unix: Some(1_700_001_000.0_f64),
2133 }];
2134 let src = BiomeMenuItemSource::new(&records, None);
2135 let acts = src.activities();
2136 assert_eq!(acts.len(), 1);
2137 assert_eq!(acts[0].source, SourceKind::BiomeMenuItem);
2138 }
2139}