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}
62
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub enum Subject {
66 Command(String),
68 File {
73 path: String,
75 volume_serial: Option<u32>,
77 },
78 Folder {
81 path: String,
83 volume_serial: Option<u32>,
85 },
86 Device {
90 id: String,
92 volume_serial: Option<u32>,
94 },
95 Query(String),
97}
98
99impl Subject {
100 #[must_use]
102 pub fn file(path: impl Into<String>) -> Self {
103 Self::File {
104 path: path.into(),
105 volume_serial: None,
106 }
107 }
108
109 #[must_use]
111 pub fn folder(path: impl Into<String>) -> Self {
112 Self::Folder {
113 path: path.into(),
114 volume_serial: None,
115 }
116 }
117}
118
119#[non_exhaustive]
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum SourceKind {
126 ShellHistory,
128 PeripheralDevice,
130 Srum,
133 Registry,
136 LnkFile,
139 JumpList,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct UserActivity {
148 pub timestamp: Option<i64>,
151 pub actor: Option<String>,
154 pub action: Action,
156 pub subject: Subject,
158 pub source: SourceKind,
160 pub detail: String,
162}
163
164pub trait ActivitySource {
170 fn activities(&self) -> Vec<UserActivity>;
172}
173
174fn is_history_tamper(cmd: &str) -> bool {
180 let c = cmd.to_ascii_lowercase();
181 let c = c.trim();
182 c.contains("unset histfile")
184 || c.contains("histfile=/dev/null")
185 || c.contains("histsize=0")
186 || c.contains("histfilesize=0")
187 || (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
188 || c.contains("history -c")
189 || (c.contains("clear-history"))
191 || (c.contains("remove-item") && c.contains("consolehost_history"))
192 || (c.contains("rm ") && c.contains(".bash_history"))
194 || (c.contains("rm ") && c.contains(".zsh_history"))
195 || (c.starts_with("> ") && c.contains("history"))
196}
197
198pub struct ShellHistorySource<'a> {
204 entries: &'a [HistoryEntry],
205 actor: Option<String>,
206}
207
208impl<'a> ShellHistorySource<'a> {
209 #[must_use]
211 pub fn new(entries: &'a [HistoryEntry]) -> Self {
212 Self {
213 entries,
214 actor: None,
215 }
216 }
217
218 #[must_use]
220 pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
221 Self {
222 entries,
223 actor: Some(actor.into()),
224 }
225 }
226}
227
228impl ActivitySource for ShellHistorySource<'_> {
229 fn activities(&self) -> Vec<UserActivity> {
230 from_shell_history(self.entries, self.actor.as_deref())
231 }
232}
233
234#[must_use]
240pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
241 entries
242 .iter()
243 .map(|e| {
244 let action = if is_history_tamper(&e.command) {
245 Action::HistoryTampered
246 } else {
247 Action::Executed
248 };
249 UserActivity {
250 timestamp: e.timestamp,
251 actor: actor.map(ToString::to_string),
252 action,
253 subject: Subject::Command(e.command.clone()),
254 source: SourceKind::ShellHistory,
255 detail: e.command.clone(),
256 }
257 })
258 .collect()
259}
260
261pub struct DeviceSource<'a> {
267 connections: &'a [DeviceConnection],
268}
269
270impl<'a> DeviceSource<'a> {
271 #[must_use]
273 pub fn new(connections: &'a [DeviceConnection]) -> Self {
274 Self { connections }
275 }
276}
277
278impl ActivitySource for DeviceSource<'_> {
279 fn activities(&self) -> Vec<UserActivity> {
280 from_device_connections(self.connections)
281 }
282}
283
284#[must_use]
290pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
291 connections
292 .iter()
293 .map(|c| {
294 let timestamp = c
295 .first_install
296 .or(c.last_arrival)
297 .or(c.last_install)
298 .map(|s| s.value);
299 UserActivity {
300 timestamp,
301 actor: None,
302 action: Action::Connected,
303 subject: Subject::Device {
304 id: c.device_instance_id.clone(),
305 volume_serial: c.volume_serial,
306 },
307 source: SourceKind::PeripheralDevice,
308 detail: c.device_instance_id.clone(),
309 }
310 })
311 .collect()
312}
313
314pub struct SrumSource<'a> {
323 network: &'a [srum_core::NetworkUsageRecord],
324 app_usage: &'a [srum_core::AppUsageRecord],
325 id_map: &'a [srum_core::IdMapEntry],
326}
327
328impl<'a> SrumSource<'a> {
329 #[must_use]
331 pub fn new(
332 network: &'a [srum_core::NetworkUsageRecord],
333 app_usage: &'a [srum_core::AppUsageRecord],
334 id_map: &'a [srum_core::IdMapEntry],
335 ) -> Self {
336 Self {
337 network,
338 app_usage,
339 id_map,
340 }
341 }
342}
343
344impl ActivitySource for SrumSource<'_> {
345 fn activities(&self) -> Vec<UserActivity> {
346 from_srum(self.network, self.app_usage, self.id_map)
347 }
348}
349
350fn resolve_id(id: i32, id_map: &[srum_core::IdMapEntry]) -> Option<String> {
355 id_map
356 .iter()
357 .find(|e| e.id == id)
358 .map(|e| e.name.clone())
359 .filter(|n| !n.is_empty())
360}
361
362#[must_use]
371pub fn from_srum(
372 network: &[srum_core::NetworkUsageRecord],
373 app_usage: &[srum_core::AppUsageRecord],
374 id_map: &[srum_core::IdMapEntry],
375) -> Vec<UserActivity> {
376 let mut acts = Vec::with_capacity(network.len() + app_usage.len());
377
378 for r in network {
379 let actor =
380 resolve_id(r.user_id, id_map).unwrap_or_else(|| format!("user-id:{}", r.user_id));
381 let app = resolve_id(r.app_id, id_map).unwrap_or_else(|| format!("app-id:{}", r.app_id));
382 acts.push(UserActivity {
383 timestamp: Some(r.timestamp.timestamp()),
384 actor: Some(actor),
385 action: Action::Executed,
386 subject: Subject::Command(app),
387 source: SourceKind::Srum,
388 detail: format!(
389 "{}\u{2191} / {}\u{2193} bytes (SRUM network usage)",
390 r.bytes_sent, r.bytes_recv
391 ),
392 });
393 }
394
395 for r in app_usage {
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 "{} foreground / {} background CPU cycles (SRUM app usage)",
407 r.foreground_cycles, r.background_cycles
408 ),
409 });
410 }
411
412 acts
413}
414
415pub struct LnkSource<'a> {
423 links: &'a [lnk_core::ShellLink],
424 actor: Option<String>,
425}
426
427impl<'a> LnkSource<'a> {
428 #[must_use]
430 pub fn new(links: &'a [lnk_core::ShellLink], actor: Option<&str>) -> Self {
431 Self {
432 links,
433 actor: actor.map(ToString::to_string),
434 }
435 }
436}
437
438impl ActivitySource for LnkSource<'_> {
439 fn activities(&self) -> Vec<UserActivity> {
440 from_lnk(self.links, self.actor.as_deref())
441 }
442}
443
444#[must_use]
453pub fn from_lnk(links: &[lnk_core::ShellLink], actor: Option<&str>) -> Vec<UserActivity> {
454 links
455 .iter()
456 .filter_map(|link| {
457 let info = link.link_info.as_ref()?;
458 let path = info.local_base_path.clone().or_else(|| {
459 info.common_network_relative_link
460 .as_ref()
461 .and_then(|c| c.net_name.clone())
462 })?;
463 let volume_serial = info.volume_id.as_ref().map(|v| v.drive_serial_number);
464 let timestamp = (link.header.write_time != 0).then_some(link.header.write_time);
466 Some(UserActivity {
467 timestamp,
468 actor: actor.map(ToString::to_string),
469 action: Action::Accessed,
470 subject: Subject::File {
471 path: path.clone(),
472 volume_serial,
473 },
474 source: SourceKind::LnkFile,
475 detail: format!("LNK target: {path}"),
476 })
477 })
478 .collect()
479}
480
481pub struct JumpListSource<'a> {
486 lists: &'a [lnk_core::JumpList],
487 actor: Option<String>,
488}
489
490impl<'a> JumpListSource<'a> {
491 #[must_use]
493 pub fn new(lists: &'a [lnk_core::JumpList], actor: Option<&str>) -> Self {
494 Self {
495 lists,
496 actor: actor.map(ToString::to_string),
497 }
498 }
499}
500
501impl ActivitySource for JumpListSource<'_> {
502 fn activities(&self) -> Vec<UserActivity> {
503 from_jumplists(self.lists, self.actor.as_deref())
504 }
505}
506
507#[must_use]
517pub fn from_jumplists(lists: &[lnk_core::JumpList], actor: Option<&str>) -> Vec<UserActivity> {
518 let mut out = Vec::new();
519 for list in lists {
520 let app = list.app_id.as_deref().map_or_else(
521 || "unknown app".to_string(),
522 |id| {
523 forensicnomicon::jumplist::appid_name(id)
524 .map_or_else(|| format!("AppID {id}"), ToString::to_string)
525 },
526 );
527 for entry in &list.entries {
528 let info = entry.link.link_info.as_ref();
529 let link_path = info.and_then(|i| {
530 i.local_base_path.clone().or_else(|| {
531 i.common_network_relative_link
532 .as_ref()
533 .and_then(|c| c.net_name.clone())
534 })
535 });
536 let path = match entry.destlist.as_ref() {
538 Some(d) if !d.path.is_empty() => Some(d.path.clone()),
539 _ => link_path,
540 };
541 let Some(path) = path else { continue };
542
543 let volume_serial =
544 info.and_then(|i| i.volume_id.as_ref().map(|v| v.drive_serial_number));
545 let dl_ts = entry
548 .destlist
549 .as_ref()
550 .and_then(|d| (d.last_access != 0).then_some(d.last_access));
551 let timestamp = dl_ts.or_else(|| {
552 (entry.link.header.write_time != 0).then_some(entry.link.header.write_time)
553 });
554
555 let detail = match entry.destlist.as_ref() {
556 Some(d) => format!("JumpList ({app}) recent item on {}: {path}", d.hostname),
557 None => format!("JumpList ({app}) recent item: {path}"),
558 };
559 out.push(UserActivity {
560 timestamp,
561 actor: actor.map(ToString::to_string),
562 action: Action::Accessed,
563 subject: Subject::File {
564 path,
565 volume_serial,
566 },
567 source: SourceKind::JumpList,
568 detail,
569 });
570 }
571 }
572 out
573}
574
575fn iso8601_to_epoch(s: Option<&str>) -> Option<i64> {
580 let s = s?;
581 chrono::DateTime::parse_from_rfc3339(s)
582 .ok()
583 .map(|dt| dt.timestamp())
584}
585
586pub struct RegistrySource<'a> {
598 userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
599 typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
600 shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
601 actor: Option<String>,
602}
603
604impl<'a> RegistrySource<'a> {
605 #[must_use]
608 pub fn new(
609 userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
610 typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
611 shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
612 actor: Option<&str>,
613 ) -> Self {
614 Self {
615 userassist,
616 typed_urls,
617 shellbags,
618 actor: actor.map(ToString::to_string),
619 }
620 }
621}
622
623impl ActivitySource for RegistrySource<'_> {
624 fn activities(&self) -> Vec<UserActivity> {
625 from_registry(
626 self.userassist,
627 self.typed_urls,
628 self.shellbags,
629 self.actor.as_deref(),
630 )
631 }
632}
633
634#[must_use]
640pub fn from_userassist(
641 entries: &[winreg_artifacts::userassist::UserAssistEntry],
642 actor: Option<&str>,
643) -> Vec<UserActivity> {
644 entries
645 .iter()
646 .map(|e| UserActivity {
647 timestamp: iso8601_to_epoch(e.last_run.as_deref()),
648 actor: actor.map(ToString::to_string),
649 action: Action::Executed,
650 subject: Subject::Command(e.program.clone()),
651 source: SourceKind::Registry,
652 detail: format!("UserAssist: {} run {} time(s)", e.program, e.run_count),
653 })
654 .collect()
655}
656
657#[must_use]
663pub fn from_typed_urls(
664 urls: &[winreg_artifacts::typed_urls::TypedUrl],
665 actor: Option<&str>,
666) -> Vec<UserActivity> {
667 urls.iter()
668 .map(|u| {
669 let detail = match &u.suspicious_reason {
670 Some(reason) => format!("TypedURL: {} ({reason})", u.url),
671 None => format!("TypedURL: {}", u.url),
672 };
673 UserActivity {
674 timestamp: iso8601_to_epoch(u.last_visited.as_deref()),
675 actor: actor.map(ToString::to_string),
676 action: Action::Typed,
677 subject: Subject::Query(u.url.clone()),
678 source: SourceKind::Registry,
679 detail,
680 }
681 })
682 .collect()
683}
684
685#[must_use]
690pub fn from_shellbags(
691 bags: &[winreg_artifacts::shellbags::ShellbagEntry],
692 actor: Option<&str>,
693) -> Vec<UserActivity> {
694 bags.iter()
695 .map(|b| UserActivity {
696 timestamp: iso8601_to_epoch(b.last_written.as_deref()),
697 actor: actor.map(ToString::to_string),
698 action: Action::Accessed,
699 subject: Subject::folder(b.path.clone()),
700 source: SourceKind::Registry,
701 detail: format!("ShellBag {}: {}", b.key_path, b.path),
702 })
703 .collect()
704}
705
706#[must_use]
711pub fn from_registry(
712 userassist: &[winreg_artifacts::userassist::UserAssistEntry],
713 typed_urls: &[winreg_artifacts::typed_urls::TypedUrl],
714 shellbags: &[winreg_artifacts::shellbags::ShellbagEntry],
715 actor: Option<&str>,
716) -> Vec<UserActivity> {
717 let mut acts = from_userassist(userassist, actor);
718 acts.extend(from_typed_urls(typed_urls, actor));
719 acts.extend(from_shellbags(shellbags, actor));
720 acts
721}
722
723#[must_use]
729pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
730 let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
731 events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
734 events
735}
736
737pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
743
744pub const NETWORK_EXFIL_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
753
754#[must_use]
756pub fn source(scope: impl Into<String>) -> Source {
757 Source {
758 analyzer: "useract-forensic".to_string(),
759 scope: scope.into(),
760 version: Some(env!("CARGO_PKG_VERSION").to_string()),
761 }
762}
763
764#[must_use]
777pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
778 let mut pairs = Vec::new();
779 for (di, dev) in events.iter().enumerate() {
780 let Subject::Device {
781 volume_serial: Some(dev_serial),
782 ..
783 } = &dev.subject
784 else {
785 continue;
786 };
787 for (fi, file) in events.iter().enumerate() {
788 if file_volume_serial(file) == Some(*dev_serial) {
789 pairs.push((di, fi));
790 }
791 }
792 }
793 pairs
794}
795
796fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
800 let structured = match &activity.subject {
801 Subject::File { volume_serial, .. } | Subject::Folder { volume_serial, .. } => {
802 *volume_serial
803 }
804 _ => return None,
805 };
806 if structured.is_some() {
807 return structured;
808 }
809 for tok in activity.detail.split_whitespace() {
810 if let Some(rest) = tok.strip_prefix("vol:") {
811 if let Ok(serial) = rest.parse::<u32>() {
812 return Some(serial);
813 }
814 }
815 }
816 None
817}
818
819#[must_use]
832pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
833 audit_with(events, &source("host"))
834}
835
836#[must_use]
838pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
839 let mut findings = Vec::new();
840
841 let media_windows: Vec<(i64, &str)> = events
849 .iter()
850 .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
851 (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
852 Some((ts, id.as_str()))
853 }
854 _ => None,
855 })
856 .collect();
857
858 for (di, fi) in device_file_volume_joins(events) {
861 findings.push(file_on_external_device_finding(
862 &events[di],
863 &events[fi],
864 src,
865 ));
866 }
867
868 for event in events {
869 if event.action == Action::HistoryTampered {
871 findings.push(history_tampered_finding(event, src));
872 continue;
873 }
874
875 if event.source == SourceKind::Srum {
878 if let Some(bytes_sent) = srum_network_bytes_sent(event) {
879 if bytes_sent >= NETWORK_EXFIL_BYTES_THRESHOLD {
880 findings.push(network_exfil_volume_finding(event, bytes_sent, src));
881 }
882 }
883 }
884
885 if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
887 (event.action, event.timestamp, &event.subject)
888 {
889 if let Some((win_ts, dev_id)) = media_windows
890 .iter()
891 .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
892 {
893 findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
894 }
895 }
896 }
897
898 findings
899}
900
901fn is_mass_storage_id(instance_id: &str) -> bool {
907 let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
908 Bus::from_enumerator(enumerator).is_mass_storage()
909}
910
911fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
912 let cmd = match &event.subject {
913 Subject::Command(c) => c.as_str(),
914 _ => event.detail.as_str(),
915 };
916 Finding::observation(
917 Severity::Medium,
918 Category::Concealment,
919 "USERACT-HISTORY-TAMPERED",
920 )
921 .source(src.clone())
922 .note(format!(
923 "user activity {cmd:?} disables or clears the activity record; consistent with \
924 anti-forensic history tampering (MITRE T1070.003)"
925 ))
926 .evidence("command", cmd.to_string())
927 .external_ref(ExternalRef::mitre_attack("T1070.003"))
928 .build()
929}
930
931fn exec_during_media_finding(
932 cmd: &str,
933 cmd_ts: i64,
934 dev_ts: i64,
935 dev_id: &str,
936 src: &Source,
937) -> Finding {
938 Finding::observation(
939 Severity::Low,
940 Category::Threat,
941 "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
942 )
943 .source(src.clone())
944 .note(format!(
945 "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
946 device {dev_id:?} being connected; consistent with activity involving external media \
947 (MITRE T1052 / T1091)"
948 ))
949 .evidence("command", cmd.to_string())
950 .evidence("device", dev_id.to_string())
951 .evidence("command_epoch", cmd_ts.to_string())
952 .evidence("device_epoch", dev_ts.to_string())
953 .external_ref(ExternalRef::mitre_attack("T1052"))
954 .external_ref(ExternalRef::mitre_attack("T1091"))
955 .build()
956}
957
958fn srum_network_bytes_sent(activity: &UserActivity) -> Option<u64> {
962 let prefix = activity.detail.split('\u{2191}').next()?;
963 prefix.trim().parse::<u64>().ok()
964}
965
966fn network_exfil_volume_finding(event: &UserActivity, bytes_sent: u64, src: &Source) -> Finding {
967 let app = match &event.subject {
968 Subject::Command(c) => c.as_str(),
969 _ => event.detail.as_str(), };
971 let actor = event.actor.as_deref().unwrap_or("(unattributed)");
972 Finding::observation(
973 Severity::Medium,
974 Category::Threat,
975 "USERACT-NETWORK-EXFIL-VOLUME",
976 )
977 .source(src.clone())
978 .note(format!(
979 "SRUM records {bytes_sent} bytes sent in one interval by {app:?} attributed to user \
980 {actor:?}; the volume exceeds the {NETWORK_EXFIL_BYTES_THRESHOLD}-byte lead threshold and \
981 is consistent with bulk data exfiltration (MITRE T1048 / T1052) — a graded lead for the \
982 examiner, not a verdict"
983 ))
984 .evidence("application", app.to_string())
985 .evidence("actor", actor.to_string())
986 .evidence("bytes_sent", bytes_sent.to_string())
987 .external_ref(ExternalRef::mitre_attack("T1048"))
988 .external_ref(ExternalRef::mitre_attack("T1052"))
989 .build()
990}
991
992fn file_on_external_device_finding(
993 device: &UserActivity,
994 file: &UserActivity,
995 src: &Source,
996) -> Finding {
997 let path = match &file.subject {
998 Subject::File { path, .. } | Subject::Folder { path, .. } => path.as_str(),
999 _ => file.detail.as_str(), };
1001 let dev_id = match &device.subject {
1002 Subject::Device { id, .. } => id.as_str(),
1003 _ => device.detail.as_str(), };
1005 let serial = match &device.subject {
1006 Subject::Device {
1007 volume_serial: Some(s),
1008 ..
1009 } => *s,
1010 _ => 0, };
1012 Finding::observation(
1013 Severity::Medium,
1014 Category::Threat,
1015 "USERACT-FILE-ON-EXTERNAL-DEVICE",
1016 )
1017 .source(src.clone())
1018 .note(format!(
1019 "a user accessed {path:?} on a volume (serial {serial:#010x}) whose serial matches the \
1020 connected external device {dev_id:?}; consistent with data movement to/from removable \
1021 media (MITRE T1052 / T1091)"
1022 ))
1023 .evidence("file", path.to_string())
1024 .evidence("device", dev_id.to_string())
1025 .evidence("volume_serial", format!("{serial:#010x}"))
1026 .external_ref(ExternalRef::mitre_attack("T1052"))
1027 .external_ref(ExternalRef::mitre_attack("T1091"))
1028 .build()
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use peripheral_core::{Bus, Provenance, Stamp};
1035 use shellhist_core::{HistoryEntry, Shell};
1036
1037 fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
1038 HistoryEntry {
1039 shell: Shell::Bash,
1040 command: cmd.to_string(),
1041 timestamp: ts,
1042 elapsed: None,
1043 paths: Vec::new(),
1044 }
1045 }
1046
1047 fn device(
1048 instance_id: &str,
1049 bus: Bus,
1050 first_install: Option<i64>,
1051 vol: Option<u32>,
1052 ) -> DeviceConnection {
1053 DeviceConnection {
1054 bus,
1055 device_class_guid: None,
1056 vid: None,
1057 pid: None,
1058 device_serial: None,
1059 serial_is_os_generated: false,
1060 friendly_name: None,
1061 device_instance_id: instance_id.to_string(),
1062 first_install: first_install.map(Stamp::authoritative),
1063 last_install: None,
1064 last_arrival: None,
1065 last_removal: None,
1066 parent_id_prefix: None,
1067 volume_guid: None,
1068 drive_letter: None,
1069 volume_serial: vol,
1070 disk_signature: None,
1071 dma_capable: bus.is_dma_capable(),
1072 mitre: Vec::new(),
1073 source: Provenance {
1074 file: "setupapi.dev.log".to_string(),
1075 line: 1,
1076 },
1077 }
1078 }
1079
1080 #[test]
1083 fn shell_command_becomes_executed_activity() {
1084 let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
1085 let acts = from_shell_history(&entries, None);
1086 assert_eq!(acts.len(), 1);
1087 assert_eq!(acts[0].action, Action::Executed);
1088 assert_eq!(acts[0].source, SourceKind::ShellHistory);
1089 assert_eq!(acts[0].timestamp, Some(1_700_000_000));
1090 assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
1091 assert_eq!(acts[0].actor, None);
1092 }
1093
1094 #[test]
1095 fn shell_actor_is_carried_when_known() {
1096 let entries = [entry("whoami", None)];
1097 let acts = from_shell_history(&entries, Some("alice"));
1098 assert_eq!(acts[0].actor.as_deref(), Some("alice"));
1099 }
1100
1101 #[test]
1102 fn history_clearing_command_becomes_tampered() {
1103 for cmd in [
1104 "unset HISTFILE",
1105 "history -c",
1106 "export HISTFILE=/dev/null",
1107 "Clear-History",
1108 "rm ~/.bash_history",
1109 ] {
1110 let entries = [entry(cmd, Some(1))];
1111 let acts = from_shell_history(&entries, None);
1112 assert_eq!(acts[0].action, Action::HistoryTampered);
1113 }
1114 }
1115
1116 #[test]
1117 fn benign_command_is_not_tampered() {
1118 let entries = [entry("git log --oneline", Some(1))];
1119 let acts = from_shell_history(&entries, None);
1120 assert_eq!(acts[0].action, Action::Executed);
1121 }
1122
1123 #[test]
1126 fn device_becomes_connected_with_volume_serial() {
1127 let conns = [device(
1128 "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
1129 Bus::Usb,
1130 Some(1_700_000_500),
1131 Some(0xDEAD_BEEF),
1132 )];
1133 let acts = from_device_connections(&conns);
1134 assert_eq!(acts.len(), 1);
1135 assert_eq!(acts[0].action, Action::Connected);
1136 assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
1137 assert_eq!(acts[0].timestamp, Some(1_700_000_500));
1138 assert_eq!(
1139 acts[0].subject,
1140 Subject::Device {
1141 id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
1142 volume_serial: Some(0xDEAD_BEEF),
1143 }
1144 );
1145 }
1146
1147 #[test]
1148 fn device_timestamp_falls_back_through_stamps() {
1149 let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
1150 conn.last_arrival = Some(Stamp::inferred(42));
1151 let acts = from_device_connections(&[conn]);
1152 assert_eq!(acts[0].timestamp, Some(42));
1153 }
1154
1155 #[test]
1156 fn device_without_any_stamp_has_no_timestamp() {
1157 let conn = device("USB\\VID_0781", Bus::Usb, None, None);
1158 let acts = from_device_connections(&[conn]);
1159 assert_eq!(acts[0].timestamp, None);
1160 }
1161
1162 #[test]
1165 fn timeline_merges_and_sorts_by_timestamp() {
1166 let entries = [entry("late", Some(300)), entry("early", Some(100))];
1167 let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
1168 let shell = ShellHistorySource::new(&entries);
1169 let devices = DeviceSource::new(&conns);
1170 let tl = build_timeline(&[&shell, &devices]);
1171 let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
1172 assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
1173 }
1174
1175 #[test]
1176 fn timeline_orders_untimestamped_events_last_and_stably() {
1177 let entries = [
1178 entry("no_ts_a", None),
1179 entry("ts", Some(50)),
1180 entry("no_ts_b", None),
1181 ];
1182 let shell = ShellHistorySource::new(&entries);
1183 let tl = build_timeline(&[&shell]);
1184 assert_eq!(tl[0].timestamp, Some(50));
1185 assert_eq!(tl[1].detail, "no_ts_a");
1186 assert_eq!(tl[2].detail, "no_ts_b");
1187 }
1188
1189 #[test]
1192 fn audit_surfaces_history_tampered() {
1193 let entries = [entry("unset HISTFILE", Some(10))];
1194 let acts = from_shell_history(&entries, None);
1195 let findings = audit(&acts);
1196 let f = findings
1197 .iter()
1198 .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
1199 .expect("history-tampered finding must fire");
1200 assert_eq!(f.severity, Some(Severity::Medium));
1201 assert_eq!(f.category, Category::Concealment);
1202 }
1203
1204 #[test]
1207 fn audit_fires_exec_during_removable_media_within_window() {
1208 let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
1209 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
1210 let shell = ShellHistorySource::new(&entries);
1211 let devices = DeviceSource::new(&conns);
1212 let tl = build_timeline(&[&shell, &devices]);
1213 let findings = audit(&tl);
1214 assert!(findings
1215 .iter()
1216 .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1217 }
1218
1219 #[test]
1220 fn audit_does_not_fire_outside_window() {
1221 let entries = [entry("ls", Some(1_000))];
1222 let conns = [device(
1223 "USBSTOR\\Disk",
1224 Bus::Usb,
1225 Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
1226 None,
1227 )];
1228 let shell = ShellHistorySource::new(&entries);
1229 let devices = DeviceSource::new(&conns);
1230 let tl = build_timeline(&[&shell, &devices]);
1231 let findings = audit(&tl);
1232 assert!(findings
1233 .iter()
1234 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1235 }
1236
1237 #[test]
1238 fn audit_does_not_fire_for_non_mass_storage_device() {
1239 let entries = [entry("ls", Some(1_000))];
1241 let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
1242 let shell = ShellHistorySource::new(&entries);
1243 let devices = DeviceSource::new(&conns);
1244 let tl = build_timeline(&[&shell, &devices]);
1245 let findings = audit(&tl);
1246 assert!(findings
1247 .iter()
1248 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1249 }
1250
1251 #[test]
1252 fn audit_with_custom_source_stamps_scope() {
1253 let entries = [entry("history -c", Some(1))];
1254 let acts = from_shell_history(&entries, None);
1255 let findings = audit_with(&acts, &source("CASE-001/host-7"));
1256 let f = &findings[0];
1257 assert_eq!(f.source.scope, "CASE-001/host-7");
1258 assert_eq!(f.source.analyzer, "useract-forensic");
1259 }
1260
1261 #[test]
1264 fn findings_are_hedged_observations_never_verdicts() {
1265 let entries = [
1266 entry("unset HISTFILE", Some(1_000)),
1267 entry("cp x /media/usb", Some(1_010)),
1268 ];
1269 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
1270 let shell = ShellHistorySource::new(&entries);
1271 let devices = DeviceSource::new(&conns);
1272 let tl = build_timeline(&[&shell, &devices]);
1273 let findings = audit(&tl);
1274 assert!(!findings.is_empty());
1275 for f in &findings {
1276 let note = f.note.to_ascii_lowercase();
1277 assert!(!note.contains("proves"));
1278 assert!(!note.contains("confirms"));
1279 assert!(!note.contains("definitely"));
1280 assert!(note.contains("consistent with"));
1281 }
1282 }
1283
1284 #[test]
1287 fn volume_serial_join_is_empty_for_v01_sources() {
1288 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1290 let acts = from_device_connections(&conns);
1291 assert!(device_file_volume_joins(&acts).is_empty());
1292 }
1293
1294 #[test]
1295 fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
1296 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1299 let mut acts = from_device_connections(&conns);
1300 acts.push(UserActivity {
1301 timestamp: Some(2),
1302 actor: None,
1303 action: Action::Accessed,
1304 subject: Subject::file("\\\\?\\E:\\secret.docx"),
1305 source: SourceKind::PeripheralDevice, detail: "opened E:\\secret.docx vol:4660".to_string(), });
1308 let joins = device_file_volume_joins(&acts);
1309 assert_eq!(joins, vec![(0, 1)]);
1310 }
1311
1312 #[test]
1313 fn volume_serial_join_ignores_mismatched_serials() {
1314 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1315 let mut acts = from_device_connections(&conns);
1316 acts.push(UserActivity {
1317 timestamp: Some(2),
1318 actor: None,
1319 action: Action::Accessed,
1320 subject: Subject::file("x"),
1321 source: SourceKind::PeripheralDevice,
1322 detail: "vol:9999".to_string(),
1323 });
1324 assert!(device_file_volume_joins(&acts).is_empty());
1325 }
1326
1327 #[test]
1328 fn volume_serial_join_skips_files_without_a_volume_token() {
1329 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1332 let mut acts = from_device_connections(&conns);
1333 acts.push(UserActivity {
1334 timestamp: Some(2),
1335 actor: None,
1336 action: Action::Accessed,
1337 subject: Subject::folder("E:\\photos"),
1338 source: SourceKind::PeripheralDevice,
1339 detail: "opened folder with no serial hint".to_string(),
1340 });
1341 acts.push(UserActivity {
1343 timestamp: Some(3),
1344 actor: None,
1345 action: Action::Accessed,
1346 subject: Subject::file("E:\\x"),
1347 source: SourceKind::PeripheralDevice,
1348 detail: "vol:notanumber".to_string(),
1349 });
1350 assert!(device_file_volume_joins(&acts).is_empty());
1351 }
1352
1353 #[test]
1354 fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
1355 let act = UserActivity {
1358 timestamp: Some(1),
1359 actor: None,
1360 action: Action::HistoryTampered,
1361 subject: Subject::file("ConsoleHost_history.txt"),
1362 source: SourceKind::ShellHistory,
1363 detail: "Remove-Item ConsoleHost_history.txt".to_string(),
1364 };
1365 let findings = audit(&[act]);
1366 assert_eq!(findings.len(), 1);
1367 assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
1368 assert!(findings[0]
1369 .note
1370 .contains("Remove-Item ConsoleHost_history.txt"));
1371 }
1372
1373 #[test]
1374 fn is_mass_storage_id_classifies_bare_and_separated_ids() {
1375 assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
1376 assert!(is_mass_storage_id("USBSTOR"));
1377 assert!(!is_mass_storage_id("BTHENUM\\Dev"));
1378 assert!(!is_mass_storage_id(""));
1379 }
1380
1381 #[test]
1382 fn activitysource_trait_dispatches() {
1383 let entries = [entry("ls", Some(1))];
1384 let s = ShellHistorySource::for_actor(&entries, "bob");
1385 let acts: Vec<UserActivity> = s.activities();
1386 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1387 }
1388
1389 use srum_core::{AppUsageRecord, IdMapEntry, NetworkUsageRecord};
1392
1393 fn utc(epoch: i64) -> chrono::DateTime<chrono::Utc> {
1394 chrono::DateTime::from_timestamp(epoch, 0).expect("valid epoch")
1395 }
1396
1397 #[test]
1398 fn srum_network_row_is_executed_and_actor_attributed() {
1399 let id_map = [
1401 IdMapEntry {
1402 id: 7,
1403 name: "S-1-5-21-1-2-3-1001".to_string(),
1404 },
1405 IdMapEntry {
1406 id: 42,
1407 name: "\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string(),
1408 },
1409 ];
1410 let net = [NetworkUsageRecord {
1411 app_id: 42,
1412 user_id: 7,
1413 timestamp: utc(1_700_000_000),
1414 bytes_sent: 4096,
1415 bytes_recv: 1024,
1416 auto_inc_id: 0,
1417 }];
1418 let acts = from_srum(&net, &[], &id_map);
1419 assert_eq!(acts.len(), 1);
1420 let a = &acts[0];
1421 assert_eq!(a.action, Action::Executed);
1422 assert_eq!(a.source, SourceKind::Srum);
1423 assert_eq!(a.timestamp, Some(1_700_000_000));
1424 assert_eq!(a.actor.as_deref(), Some("S-1-5-21-1-2-3-1001"));
1426 assert_eq!(
1428 a.subject,
1429 Subject::Command("\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string())
1430 );
1431 assert!(a.detail.contains("4096"));
1433 assert!(a.detail.contains("1024"));
1434 }
1435
1436 #[test]
1437 fn srum_unresolved_user_id_falls_back_to_numeric_token() {
1438 let net = [NetworkUsageRecord {
1440 app_id: 1,
1441 user_id: 99,
1442 timestamp: utc(10),
1443 bytes_sent: 1,
1444 bytes_recv: 2,
1445 auto_inc_id: 0,
1446 }];
1447 let acts = from_srum(&net, &[], &[]);
1448 assert_eq!(acts.len(), 1);
1449 assert_eq!(acts[0].actor.as_deref(), Some("user-id:99"));
1450 assert_eq!(acts[0].subject, Subject::Command("app-id:1".to_string()));
1452 }
1453
1454 #[test]
1455 fn srum_app_usage_row_is_executed_and_actor_attributed() {
1456 let id_map = [
1457 IdMapEntry {
1458 id: 5,
1459 name: "S-1-5-21-9-9-9-500".to_string(),
1460 },
1461 IdMapEntry {
1462 id: 8,
1463 name: "C:\\Tools\\rclone.exe".to_string(),
1464 },
1465 ];
1466 let app = [AppUsageRecord {
1467 app_id: 8,
1468 user_id: 5,
1469 timestamp: utc(1_700_000_500),
1470 foreground_cycles: 900_000,
1471 background_cycles: 100,
1472 auto_inc_id: 0,
1473 }];
1474 let acts = from_srum(&[], &app, &id_map);
1475 assert_eq!(acts.len(), 1);
1476 assert_eq!(acts[0].action, Action::Executed);
1477 assert_eq!(acts[0].source, SourceKind::Srum);
1478 assert_eq!(acts[0].actor.as_deref(), Some("S-1-5-21-9-9-9-500"));
1479 assert_eq!(
1480 acts[0].subject,
1481 Subject::Command("C:\\Tools\\rclone.exe".to_string())
1482 );
1483 }
1484
1485 #[test]
1486 fn srum_source_adapter_dispatches() {
1487 let net = [NetworkUsageRecord {
1488 app_id: 1,
1489 user_id: 1,
1490 timestamp: utc(1),
1491 bytes_sent: 1,
1492 bytes_recv: 1,
1493 auto_inc_id: 0,
1494 }];
1495 let s = SrumSource::new(&net, &[], &[]);
1496 let acts = s.activities();
1497 assert_eq!(acts.len(), 1);
1498 assert_eq!(acts[0].source, SourceKind::Srum);
1499 }
1500
1501 #[test]
1504 fn audit_fires_network_exfil_volume_above_threshold() {
1505 let id_map = [
1506 IdMapEntry {
1507 id: 7,
1508 name: "S-1-5-21-1-2-3-1001".to_string(),
1509 },
1510 IdMapEntry {
1511 id: 42,
1512 name: "rclone.exe".to_string(),
1513 },
1514 ];
1515 let net = [NetworkUsageRecord {
1516 app_id: 42,
1517 user_id: 7,
1518 timestamp: utc(1_700_000_000),
1519 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD + 1,
1520 bytes_recv: 0,
1521 auto_inc_id: 0,
1522 }];
1523 let acts = from_srum(&net, &[], &id_map);
1524 let findings = audit(&acts);
1525 let f = findings
1526 .iter()
1527 .find(|f| f.code == "USERACT-NETWORK-EXFIL-VOLUME")
1528 .expect("network-exfil-volume must fire above threshold");
1529 assert_eq!(f.severity, Some(Severity::Medium));
1530 assert_eq!(f.category, Category::Threat);
1531 }
1532
1533 #[test]
1534 fn audit_does_not_fire_network_exfil_below_threshold() {
1535 let net = [NetworkUsageRecord {
1536 app_id: 1,
1537 user_id: 1,
1538 timestamp: utc(1),
1539 bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD - 1,
1540 bytes_recv: 0,
1541 auto_inc_id: 0,
1542 }];
1543 let acts = from_srum(&net, &[], &[]);
1544 let findings = audit(&acts);
1545 assert!(findings
1546 .iter()
1547 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1548 }
1549
1550 #[test]
1551 fn audit_skips_exfil_check_for_srum_app_usage_rows() {
1552 let app = [AppUsageRecord {
1556 app_id: 1,
1557 user_id: 1,
1558 timestamp: utc(1),
1559 foreground_cycles: u64::MAX,
1560 background_cycles: u64::MAX,
1561 auto_inc_id: 0,
1562 }];
1563 let acts = from_srum(&[], &app, &[]);
1564 let findings = audit(&acts);
1565 assert!(findings
1566 .iter()
1567 .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1568 }
1569
1570 use winreg_artifacts::shellbags::ShellbagEntry;
1573 use winreg_artifacts::typed_urls::TypedUrl;
1574 use winreg_artifacts::userassist::UserAssistEntry;
1575
1576 fn ua(program: &str, run_count: u32, last_run: Option<&str>) -> UserAssistEntry {
1577 UserAssistEntry {
1578 program: program.to_string(),
1579 run_count,
1580 focus_count: 0,
1581 focus_duration_ms: 0,
1582 last_run: last_run.map(ToString::to_string),
1583 guid: "{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}".to_string(),
1584 }
1585 }
1586
1587 #[test]
1588 fn userassist_entry_becomes_executed_with_run_count() {
1589 let entries = [ua(
1590 "C:\\Windows\\System32\\cmd.exe",
1591 5,
1592 Some("2024-06-15T08:00:00Z"),
1593 )];
1594 let acts = from_userassist(&entries, Some("alice"));
1595 assert_eq!(acts.len(), 1);
1596 let a = &acts[0];
1597 assert_eq!(a.action, Action::Executed);
1598 assert_eq!(a.source, SourceKind::Registry);
1599 assert_eq!(
1600 a.subject,
1601 Subject::Command("C:\\Windows\\System32\\cmd.exe".to_string())
1602 );
1603 assert_eq!(a.timestamp, Some(1_718_438_400));
1605 assert_eq!(a.actor.as_deref(), Some("alice"));
1606 assert!(a.detail.contains('5'));
1608 }
1609
1610 #[test]
1611 fn userassist_without_last_run_has_no_timestamp() {
1612 let entries = [ua("notepad.exe", 1, None)];
1613 let acts = from_userassist(&entries, None);
1614 assert_eq!(acts[0].timestamp, None);
1615 assert_eq!(acts[0].actor, None);
1616 }
1617
1618 #[test]
1619 fn typed_url_becomes_typed_activity() {
1620 let urls = [TypedUrl {
1621 url: "https://pastebin.com/abc".to_string(),
1622 last_visited: Some("2024-01-02T03:04:05Z".to_string()),
1623 is_suspicious: true,
1624 suspicious_reason: Some("suspicious domain: pastebin.com".to_string()),
1625 }];
1626 let acts = from_typed_urls(&urls, None);
1627 assert_eq!(acts.len(), 1);
1628 assert_eq!(acts[0].action, Action::Typed);
1629 assert_eq!(acts[0].source, SourceKind::Registry);
1630 assert_eq!(
1631 acts[0].subject,
1632 Subject::Query("https://pastebin.com/abc".to_string())
1633 );
1634 assert!(acts[0].timestamp.is_some());
1635 }
1636
1637 #[test]
1638 fn shellbag_becomes_accessed_folder() {
1639 let bags = [ShellbagEntry {
1640 path: "BagMRU[slot=0, size=120 bytes]".to_string(),
1641 key_path: "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0".to_string(),
1642 last_written: Some("2024-03-04T05:06:07Z".to_string()),
1643 mru_order: vec!["0".to_string()],
1644 }];
1645 let acts = from_shellbags(&bags, Some("bob"));
1646 assert_eq!(acts.len(), 1);
1647 assert_eq!(acts[0].action, Action::Accessed);
1648 assert_eq!(acts[0].source, SourceKind::Registry);
1649 assert!(matches!(acts[0].subject, Subject::Folder { .. }));
1650 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1651 }
1652
1653 #[test]
1654 fn from_registry_merges_all_three_registry_artifacts() {
1655 let ua_entries = [ua("cmd.exe", 1, Some("2024-06-15T08:00:00Z"))];
1656 let urls = [TypedUrl {
1657 url: "https://x.test".to_string(),
1658 last_visited: None,
1659 is_suspicious: false,
1660 suspicious_reason: None,
1661 }];
1662 let bags = [ShellbagEntry {
1663 path: "BagMRU[slot=0, size=10 bytes]".to_string(),
1664 key_path: "k".to_string(),
1665 last_written: None,
1666 mru_order: vec![],
1667 }];
1668 let acts = from_registry(&ua_entries, &urls, &bags, Some("alice"));
1669 assert_eq!(acts.len(), 3);
1670 assert!(acts.iter().any(|a| a.action == Action::Executed));
1671 assert!(acts.iter().any(|a| a.action == Action::Typed));
1672 assert!(acts.iter().any(|a| a.action == Action::Accessed));
1673 assert!(acts.iter().all(|a| a.source == SourceKind::Registry));
1674 assert!(acts.iter().all(|a| a.actor.as_deref() == Some("alice")));
1675 }
1676
1677 #[test]
1678 fn registry_source_adapter_dispatches() {
1679 let ua_entries = [ua("cmd.exe", 1, None)];
1680 let s = RegistrySource::new(&ua_entries, &[], &[], None);
1681 let acts = s.activities();
1682 assert_eq!(acts.len(), 1);
1683 assert_eq!(acts[0].source, SourceKind::Registry);
1684 }
1685
1686 use lnk_core::{LinkInfo, ShellLink, ShellLinkHeader, StringData, VolumeId};
1689
1690 fn shell_link(
1691 local_base_path: Option<&str>,
1692 drive_serial: Option<u32>,
1693 write_time: i64,
1694 net_name: Option<&str>,
1695 ) -> ShellLink {
1696 let volume_id = drive_serial.map(|s| VolumeId {
1697 drive_type: lnk_core::drive_type::REMOVABLE,
1698 drive_serial_number: s,
1699 volume_label: None,
1700 });
1701 let cnrl = net_name.map(|n| lnk_core::CommonNetworkRelativeLink {
1702 net_name: Some(n.to_string()),
1703 device_name: None,
1704 });
1705 ShellLink {
1706 header: ShellLinkHeader {
1707 link_flags: 0,
1708 file_attributes: 0,
1709 creation_time: 0,
1710 access_time: 0,
1711 write_time,
1712 file_size: 0,
1713 icon_index: 0,
1714 show_command: 1,
1715 hotkey: 0,
1716 },
1717 link_target_idlist: None,
1718 link_info: Some(LinkInfo {
1719 volume_id,
1720 local_base_path: local_base_path.map(ToString::to_string),
1721 common_network_relative_link: cnrl,
1722 }),
1723 string_data: StringData::default(),
1724 tracker: None,
1725 }
1726 }
1727
1728 #[test]
1729 fn lnk_target_becomes_accessed_file_with_volume_serial() {
1730 let links = [shell_link(
1731 Some("E:\\secret.docx"),
1732 Some(0xDEAD_BEEF),
1733 1_700_000_000,
1734 None,
1735 )];
1736 let acts = from_lnk(&links, Some("alice"));
1737 assert_eq!(acts.len(), 1);
1738 let a = &acts[0];
1739 assert_eq!(a.action, Action::Accessed);
1740 assert_eq!(a.source, SourceKind::LnkFile);
1741 assert_eq!(a.timestamp, Some(1_700_000_000));
1743 assert_eq!(a.actor.as_deref(), Some("alice"));
1744 assert_eq!(
1746 a.subject,
1747 Subject::File {
1748 path: "E:\\secret.docx".to_string(),
1749 volume_serial: Some(0xDEAD_BEEF),
1750 }
1751 );
1752 }
1753
1754 #[test]
1755 fn lnk_without_volume_id_has_no_serial() {
1756 let links = [shell_link(Some("C:\\x.txt"), None, 0, None)];
1757 let acts = from_lnk(&links, None);
1758 assert_eq!(acts.len(), 1);
1759 assert_eq!(
1760 acts[0].subject,
1761 Subject::File {
1762 path: "C:\\x.txt".to_string(),
1763 volume_serial: None,
1764 }
1765 );
1766 assert_eq!(acts[0].timestamp, None);
1768 }
1769
1770 #[test]
1771 fn lnk_network_target_falls_back_to_unc_path() {
1772 let links = [shell_link(None, None, 5, Some("\\\\server\\share"))];
1774 let acts = from_lnk(&links, None);
1775 assert_eq!(acts.len(), 1);
1776 assert_eq!(
1777 acts[0].subject,
1778 Subject::File {
1779 path: "\\\\server\\share".to_string(),
1780 volume_serial: None,
1781 }
1782 );
1783 }
1784
1785 #[test]
1786 fn lnk_without_link_info_is_skipped() {
1787 let mut link = shell_link(None, None, 0, None);
1789 link.link_info = None;
1790 let acts = from_lnk(&[link], None);
1791 assert!(acts.is_empty());
1792 }
1793
1794 fn destlist(path: &str, host: &str, last_access: i64) -> lnk_core::DestListEntry {
1795 lnk_core::DestListEntry {
1796 droid_volume_guid: String::new(),
1797 droid_file_guid: String::new(),
1798 birth_droid_volume_guid: String::new(),
1799 birth_droid_file_guid: String::new(),
1800 hostname: host.to_string(),
1801 entry_number: 1,
1802 last_access,
1803 pinned: false,
1804 access_count: Some(3),
1805 path: path.to_string(),
1806 }
1807 }
1808
1809 #[test]
1810 fn jumplist_automatic_entry_becomes_accessed_file() {
1811 let link = shell_link(
1815 Some("C:\\Users\\bob\\q3.xlsx"),
1816 Some(0x1234_5678),
1817 1_700_000_000,
1818 None,
1819 );
1820 let lists = [lnk_core::JumpList {
1821 kind: lnk_core::JumpListKind::Automatic,
1822 app_id: Some("1b4dd67f29cb1962".to_string()),
1823 entries: vec![lnk_core::JumpListEntry {
1824 destlist: Some(destlist("C:\\Users\\bob\\q3.xlsx", "WS01", 1_700_000_500)),
1825 link,
1826 }],
1827 }];
1828 let acts = from_jumplists(&lists, Some("bob"));
1829 assert_eq!(acts.len(), 1);
1830 let a = &acts[0];
1831 assert_eq!(a.action, Action::Accessed);
1832 assert_eq!(a.source, SourceKind::JumpList);
1833 assert_eq!(a.timestamp, Some(1_700_000_500));
1836 assert_eq!(a.actor.as_deref(), Some("bob"));
1837 assert_eq!(
1838 a.subject,
1839 Subject::File {
1840 path: "C:\\Users\\bob\\q3.xlsx".to_string(),
1841 volume_serial: Some(0x1234_5678),
1842 }
1843 );
1844 }
1845
1846 #[test]
1847 fn jumplist_custom_entry_falls_back_to_embedded_link() {
1848 let link = shell_link(
1851 Some("D:\\report.pdf"),
1852 Some(0xAABB_CCDD),
1853 1_690_000_000,
1854 None,
1855 );
1856 let lists = [lnk_core::JumpList {
1857 kind: lnk_core::JumpListKind::Custom,
1858 app_id: None,
1859 entries: vec![lnk_core::JumpListEntry {
1860 destlist: None,
1861 link,
1862 }],
1863 }];
1864 let acts = from_jumplists(&lists, None);
1865 assert_eq!(acts.len(), 1);
1866 assert_eq!(acts[0].source, SourceKind::JumpList);
1867 assert_eq!(acts[0].timestamp, Some(1_690_000_000));
1868 assert_eq!(
1869 acts[0].subject,
1870 Subject::File {
1871 path: "D:\\report.pdf".to_string(),
1872 volume_serial: Some(0xAABB_CCDD),
1873 }
1874 );
1875 }
1876
1877 #[test]
1878 fn lnk_source_adapter_dispatches() {
1879 let links = [shell_link(Some("E:\\f"), Some(1), 1, None)];
1880 let s = LnkSource::new(&links, None);
1881 let acts = s.activities();
1882 assert_eq!(acts.len(), 1);
1883 assert_eq!(acts[0].source, SourceKind::LnkFile);
1884 }
1885
1886 #[test]
1889 fn lnk_file_joins_connected_device_on_volume_serial() {
1890 let links = [shell_link(
1891 Some("E:\\loot.zip"),
1892 Some(0xCAFE_F00D),
1893 100,
1894 None,
1895 )];
1896 let conns = [device(
1897 "USBSTOR\\Disk",
1898 Bus::Usb,
1899 Some(50),
1900 Some(0xCAFE_F00D),
1901 )];
1902 let lnk = LnkSource::new(&links, Some("alice"));
1903 let devices = DeviceSource::new(&conns);
1904 let timeline = build_timeline(&[&lnk, &devices]);
1905 let findings = audit(&timeline);
1906 let f = findings
1907 .iter()
1908 .find(|f| f.code == "USERACT-FILE-ON-EXTERNAL-DEVICE")
1909 .expect("file-on-external-device must fire when serials match");
1910 assert_eq!(f.severity, Some(Severity::Medium));
1911 assert_eq!(f.category, Category::Threat);
1912 }
1913}