1#![forbid(unsafe_code)]
40
41use forensicnomicon::report::{Category, ExternalRef, Finding, Severity, Source};
42use peripheral_core::{Bus, DeviceConnection};
43use shellhist_core::HistoryEntry;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Action {
48 Executed,
50 Accessed,
52 Connected,
54 Searched,
56 Typed,
58 HistoryTampered,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub enum Subject {
65 Command(String),
67 File(String),
69 Folder(String),
71 Device {
75 id: String,
77 volume_serial: Option<u32>,
79 },
80 Query(String),
82}
83
84#[non_exhaustive]
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub enum SourceKind {
92 ShellHistory,
94 PeripheralDevice,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct UserActivity {
101 pub timestamp: Option<i64>,
104 pub actor: Option<String>,
107 pub action: Action,
109 pub subject: Subject,
111 pub source: SourceKind,
113 pub detail: String,
115}
116
117pub trait ActivitySource {
123 fn activities(&self) -> Vec<UserActivity>;
125}
126
127fn is_history_tamper(cmd: &str) -> bool {
133 let c = cmd.to_ascii_lowercase();
134 let c = c.trim();
135 c.contains("unset histfile")
137 || c.contains("histfile=/dev/null")
138 || c.contains("histsize=0")
139 || c.contains("histfilesize=0")
140 || (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
141 || c.contains("history -c")
142 || (c.contains("clear-history"))
144 || (c.contains("remove-item") && c.contains("consolehost_history"))
145 || (c.contains("rm ") && c.contains(".bash_history"))
147 || (c.contains("rm ") && c.contains(".zsh_history"))
148 || (c.starts_with("> ") && c.contains("history"))
149}
150
151pub struct ShellHistorySource<'a> {
157 entries: &'a [HistoryEntry],
158 actor: Option<String>,
159}
160
161impl<'a> ShellHistorySource<'a> {
162 #[must_use]
164 pub fn new(entries: &'a [HistoryEntry]) -> Self {
165 Self {
166 entries,
167 actor: None,
168 }
169 }
170
171 #[must_use]
173 pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
174 Self {
175 entries,
176 actor: Some(actor.into()),
177 }
178 }
179}
180
181impl ActivitySource for ShellHistorySource<'_> {
182 fn activities(&self) -> Vec<UserActivity> {
183 from_shell_history(self.entries, self.actor.as_deref())
184 }
185}
186
187#[must_use]
193pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
194 entries
195 .iter()
196 .map(|e| {
197 let action = if is_history_tamper(&e.command) {
198 Action::HistoryTampered
199 } else {
200 Action::Executed
201 };
202 UserActivity {
203 timestamp: e.timestamp,
204 actor: actor.map(ToString::to_string),
205 action,
206 subject: Subject::Command(e.command.clone()),
207 source: SourceKind::ShellHistory,
208 detail: e.command.clone(),
209 }
210 })
211 .collect()
212}
213
214pub struct DeviceSource<'a> {
220 connections: &'a [DeviceConnection],
221}
222
223impl<'a> DeviceSource<'a> {
224 #[must_use]
226 pub fn new(connections: &'a [DeviceConnection]) -> Self {
227 Self { connections }
228 }
229}
230
231impl ActivitySource for DeviceSource<'_> {
232 fn activities(&self) -> Vec<UserActivity> {
233 from_device_connections(self.connections)
234 }
235}
236
237#[must_use]
243pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
244 connections
245 .iter()
246 .map(|c| {
247 let timestamp = c
248 .first_install
249 .or(c.last_arrival)
250 .or(c.last_install)
251 .map(|s| s.value);
252 UserActivity {
253 timestamp,
254 actor: None,
255 action: Action::Connected,
256 subject: Subject::Device {
257 id: c.device_instance_id.clone(),
258 volume_serial: c.volume_serial,
259 },
260 source: SourceKind::PeripheralDevice,
261 detail: c.device_instance_id.clone(),
262 }
263 })
264 .collect()
265}
266
267#[must_use]
273pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
274 let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
275 events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
278 events
279}
280
281pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
287
288#[must_use]
290pub fn source(scope: impl Into<String>) -> Source {
291 Source {
292 analyzer: "useract-forensic".to_string(),
293 scope: scope.into(),
294 version: Some(env!("CARGO_PKG_VERSION").to_string()),
295 }
296}
297
298#[must_use]
313pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
314 let mut pairs = Vec::new();
315 for (di, dev) in events.iter().enumerate() {
316 let Subject::Device {
317 volume_serial: Some(dev_serial),
318 ..
319 } = &dev.subject
320 else {
321 continue;
322 };
323 for (fi, file) in events.iter().enumerate() {
324 let is_file = matches!(file.subject, Subject::File(_) | Subject::Folder(_));
325 if is_file && file_volume_serial(file) == Some(*dev_serial) {
326 pairs.push((di, fi));
327 }
328 }
329 }
330 pairs
331}
332
333fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
336 for tok in activity.detail.split_whitespace() {
337 if let Some(rest) = tok.strip_prefix("vol:") {
338 if let Ok(serial) = rest.parse::<u32>() {
339 return Some(serial);
340 }
341 }
342 }
343 None
344}
345
346#[must_use]
359pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
360 audit_with(events, &source("host"))
361}
362
363#[must_use]
365pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
366 let mut findings = Vec::new();
367
368 let media_windows: Vec<(i64, &str)> = events
376 .iter()
377 .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
378 (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
379 Some((ts, id.as_str()))
380 }
381 _ => None,
382 })
383 .collect();
384
385 for event in events {
386 if event.action == Action::HistoryTampered {
388 findings.push(history_tampered_finding(event, src));
389 continue;
390 }
391
392 if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
394 (event.action, event.timestamp, &event.subject)
395 {
396 if let Some((win_ts, dev_id)) = media_windows
397 .iter()
398 .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
399 {
400 findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
401 }
402 }
403 }
404
405 findings
406}
407
408fn is_mass_storage_id(instance_id: &str) -> bool {
414 let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
415 Bus::from_enumerator(enumerator).is_mass_storage()
416}
417
418fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
419 let cmd = match &event.subject {
420 Subject::Command(c) => c.as_str(),
421 _ => event.detail.as_str(),
422 };
423 Finding::observation(
424 Severity::Medium,
425 Category::Concealment,
426 "USERACT-HISTORY-TAMPERED",
427 )
428 .source(src.clone())
429 .note(format!(
430 "user activity {cmd:?} disables or clears the activity record; consistent with \
431 anti-forensic history tampering (MITRE T1070.003)"
432 ))
433 .evidence("command", cmd.to_string())
434 .external_ref(ExternalRef::mitre_attack("T1070.003"))
435 .build()
436}
437
438fn exec_during_media_finding(
439 cmd: &str,
440 cmd_ts: i64,
441 dev_ts: i64,
442 dev_id: &str,
443 src: &Source,
444) -> Finding {
445 Finding::observation(
446 Severity::Low,
447 Category::Threat,
448 "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
449 )
450 .source(src.clone())
451 .note(format!(
452 "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
453 device {dev_id:?} being connected; consistent with activity involving external media \
454 (MITRE T1052 / T1091)"
455 ))
456 .evidence("command", cmd.to_string())
457 .evidence("device", dev_id.to_string())
458 .evidence("command_epoch", cmd_ts.to_string())
459 .evidence("device_epoch", dev_ts.to_string())
460 .external_ref(ExternalRef::mitre_attack("T1052"))
461 .external_ref(ExternalRef::mitre_attack("T1091"))
462 .build()
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use peripheral_core::{Bus, Provenance, Stamp};
469 use shellhist_core::{HistoryEntry, Shell};
470
471 fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
472 HistoryEntry {
473 shell: Shell::Bash,
474 command: cmd.to_string(),
475 timestamp: ts,
476 elapsed: None,
477 paths: Vec::new(),
478 }
479 }
480
481 fn device(
482 instance_id: &str,
483 bus: Bus,
484 first_install: Option<i64>,
485 vol: Option<u32>,
486 ) -> DeviceConnection {
487 DeviceConnection {
488 bus,
489 device_class_guid: None,
490 vid: None,
491 pid: None,
492 device_serial: None,
493 serial_is_os_generated: false,
494 friendly_name: None,
495 device_instance_id: instance_id.to_string(),
496 first_install: first_install.map(Stamp::authoritative),
497 last_install: None,
498 last_arrival: None,
499 last_removal: None,
500 parent_id_prefix: None,
501 volume_guid: None,
502 drive_letter: None,
503 volume_serial: vol,
504 disk_signature: None,
505 dma_capable: bus.is_dma_capable(),
506 mitre: Vec::new(),
507 source: Provenance {
508 file: "setupapi.dev.log".to_string(),
509 line: 1,
510 },
511 }
512 }
513
514 #[test]
517 fn shell_command_becomes_executed_activity() {
518 let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
519 let acts = from_shell_history(&entries, None);
520 assert_eq!(acts.len(), 1);
521 assert_eq!(acts[0].action, Action::Executed);
522 assert_eq!(acts[0].source, SourceKind::ShellHistory);
523 assert_eq!(acts[0].timestamp, Some(1_700_000_000));
524 assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
525 assert_eq!(acts[0].actor, None);
526 }
527
528 #[test]
529 fn shell_actor_is_carried_when_known() {
530 let entries = [entry("whoami", None)];
531 let acts = from_shell_history(&entries, Some("alice"));
532 assert_eq!(acts[0].actor.as_deref(), Some("alice"));
533 }
534
535 #[test]
536 fn history_clearing_command_becomes_tampered() {
537 for cmd in [
538 "unset HISTFILE",
539 "history -c",
540 "export HISTFILE=/dev/null",
541 "Clear-History",
542 "rm ~/.bash_history",
543 ] {
544 let entries = [entry(cmd, Some(1))];
545 let acts = from_shell_history(&entries, None);
546 assert_eq!(acts[0].action, Action::HistoryTampered);
547 }
548 }
549
550 #[test]
551 fn benign_command_is_not_tampered() {
552 let entries = [entry("git log --oneline", Some(1))];
553 let acts = from_shell_history(&entries, None);
554 assert_eq!(acts[0].action, Action::Executed);
555 }
556
557 #[test]
560 fn device_becomes_connected_with_volume_serial() {
561 let conns = [device(
562 "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
563 Bus::Usb,
564 Some(1_700_000_500),
565 Some(0xDEAD_BEEF),
566 )];
567 let acts = from_device_connections(&conns);
568 assert_eq!(acts.len(), 1);
569 assert_eq!(acts[0].action, Action::Connected);
570 assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
571 assert_eq!(acts[0].timestamp, Some(1_700_000_500));
572 assert_eq!(
573 acts[0].subject,
574 Subject::Device {
575 id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
576 volume_serial: Some(0xDEAD_BEEF),
577 }
578 );
579 }
580
581 #[test]
582 fn device_timestamp_falls_back_through_stamps() {
583 let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
584 conn.last_arrival = Some(Stamp::inferred(42));
585 let acts = from_device_connections(&[conn]);
586 assert_eq!(acts[0].timestamp, Some(42));
587 }
588
589 #[test]
590 fn device_without_any_stamp_has_no_timestamp() {
591 let conn = device("USB\\VID_0781", Bus::Usb, None, None);
592 let acts = from_device_connections(&[conn]);
593 assert_eq!(acts[0].timestamp, None);
594 }
595
596 #[test]
599 fn timeline_merges_and_sorts_by_timestamp() {
600 let entries = [entry("late", Some(300)), entry("early", Some(100))];
601 let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
602 let shell = ShellHistorySource::new(&entries);
603 let devices = DeviceSource::new(&conns);
604 let tl = build_timeline(&[&shell, &devices]);
605 let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
606 assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
607 }
608
609 #[test]
610 fn timeline_orders_untimestamped_events_last_and_stably() {
611 let entries = [
612 entry("no_ts_a", None),
613 entry("ts", Some(50)),
614 entry("no_ts_b", None),
615 ];
616 let shell = ShellHistorySource::new(&entries);
617 let tl = build_timeline(&[&shell]);
618 assert_eq!(tl[0].timestamp, Some(50));
619 assert_eq!(tl[1].detail, "no_ts_a");
620 assert_eq!(tl[2].detail, "no_ts_b");
621 }
622
623 #[test]
626 fn audit_surfaces_history_tampered() {
627 let entries = [entry("unset HISTFILE", Some(10))];
628 let acts = from_shell_history(&entries, None);
629 let findings = audit(&acts);
630 let f = findings
631 .iter()
632 .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
633 .expect("history-tampered finding must fire");
634 assert_eq!(f.severity, Some(Severity::Medium));
635 assert_eq!(f.category, Category::Concealment);
636 }
637
638 #[test]
641 fn audit_fires_exec_during_removable_media_within_window() {
642 let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
643 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
644 let shell = ShellHistorySource::new(&entries);
645 let devices = DeviceSource::new(&conns);
646 let tl = build_timeline(&[&shell, &devices]);
647 let findings = audit(&tl);
648 assert!(findings
649 .iter()
650 .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
651 }
652
653 #[test]
654 fn audit_does_not_fire_outside_window() {
655 let entries = [entry("ls", Some(1_000))];
656 let conns = [device(
657 "USBSTOR\\Disk",
658 Bus::Usb,
659 Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
660 None,
661 )];
662 let shell = ShellHistorySource::new(&entries);
663 let devices = DeviceSource::new(&conns);
664 let tl = build_timeline(&[&shell, &devices]);
665 let findings = audit(&tl);
666 assert!(findings
667 .iter()
668 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
669 }
670
671 #[test]
672 fn audit_does_not_fire_for_non_mass_storage_device() {
673 let entries = [entry("ls", Some(1_000))];
675 let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
676 let shell = ShellHistorySource::new(&entries);
677 let devices = DeviceSource::new(&conns);
678 let tl = build_timeline(&[&shell, &devices]);
679 let findings = audit(&tl);
680 assert!(findings
681 .iter()
682 .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
683 }
684
685 #[test]
686 fn audit_with_custom_source_stamps_scope() {
687 let entries = [entry("history -c", Some(1))];
688 let acts = from_shell_history(&entries, None);
689 let findings = audit_with(&acts, &source("CASE-001/host-7"));
690 let f = &findings[0];
691 assert_eq!(f.source.scope, "CASE-001/host-7");
692 assert_eq!(f.source.analyzer, "useract-forensic");
693 }
694
695 #[test]
698 fn findings_are_hedged_observations_never_verdicts() {
699 let entries = [
700 entry("unset HISTFILE", Some(1_000)),
701 entry("cp x /media/usb", Some(1_010)),
702 ];
703 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
704 let shell = ShellHistorySource::new(&entries);
705 let devices = DeviceSource::new(&conns);
706 let tl = build_timeline(&[&shell, &devices]);
707 let findings = audit(&tl);
708 assert!(!findings.is_empty());
709 for f in &findings {
710 let note = f.note.to_ascii_lowercase();
711 assert!(!note.contains("proves"));
712 assert!(!note.contains("confirms"));
713 assert!(!note.contains("definitely"));
714 assert!(note.contains("consistent with"));
715 }
716 }
717
718 #[test]
721 fn volume_serial_join_is_empty_for_v01_sources() {
722 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
724 let acts = from_device_connections(&conns);
725 assert!(device_file_volume_joins(&acts).is_empty());
726 }
727
728 #[test]
729 fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
730 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
733 let mut acts = from_device_connections(&conns);
734 acts.push(UserActivity {
735 timestamp: Some(2),
736 actor: None,
737 action: Action::Accessed,
738 subject: Subject::File("\\\\?\\E:\\secret.docx".to_string()),
739 source: SourceKind::PeripheralDevice, detail: "opened E:\\secret.docx vol:4660".to_string(), });
742 let joins = device_file_volume_joins(&acts);
743 assert_eq!(joins, vec![(0, 1)]);
744 }
745
746 #[test]
747 fn volume_serial_join_ignores_mismatched_serials() {
748 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
749 let mut acts = from_device_connections(&conns);
750 acts.push(UserActivity {
751 timestamp: Some(2),
752 actor: None,
753 action: Action::Accessed,
754 subject: Subject::File("x".to_string()),
755 source: SourceKind::PeripheralDevice,
756 detail: "vol:9999".to_string(),
757 });
758 assert!(device_file_volume_joins(&acts).is_empty());
759 }
760
761 #[test]
762 fn volume_serial_join_skips_files_without_a_volume_token() {
763 let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
766 let mut acts = from_device_connections(&conns);
767 acts.push(UserActivity {
768 timestamp: Some(2),
769 actor: None,
770 action: Action::Accessed,
771 subject: Subject::Folder("E:\\photos".to_string()),
772 source: SourceKind::PeripheralDevice,
773 detail: "opened folder with no serial hint".to_string(),
774 });
775 acts.push(UserActivity {
777 timestamp: Some(3),
778 actor: None,
779 action: Action::Accessed,
780 subject: Subject::File("E:\\x".to_string()),
781 source: SourceKind::PeripheralDevice,
782 detail: "vol:notanumber".to_string(),
783 });
784 assert!(device_file_volume_joins(&acts).is_empty());
785 }
786
787 #[test]
788 fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
789 let act = UserActivity {
792 timestamp: Some(1),
793 actor: None,
794 action: Action::HistoryTampered,
795 subject: Subject::File("ConsoleHost_history.txt".to_string()),
796 source: SourceKind::ShellHistory,
797 detail: "Remove-Item ConsoleHost_history.txt".to_string(),
798 };
799 let findings = audit(&[act]);
800 assert_eq!(findings.len(), 1);
801 assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
802 assert!(findings[0]
803 .note
804 .contains("Remove-Item ConsoleHost_history.txt"));
805 }
806
807 #[test]
808 fn is_mass_storage_id_classifies_bare_and_separated_ids() {
809 assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
810 assert!(is_mass_storage_id("USBSTOR"));
811 assert!(!is_mass_storage_id("BTHENUM\\Dev"));
812 assert!(!is_mass_storage_id(""));
813 }
814
815 #[test]
816 fn activitysource_trait_dispatches() {
817 let entries = [entry("ls", Some(1))];
818 let s = ShellHistorySource::for_actor(&entries, "bob");
819 let acts: Vec<UserActivity> = s.activities();
820 assert_eq!(acts[0].actor.as_deref(), Some("bob"));
821 }
822}