#![forbid(unsafe_code)]
use forensicnomicon::report::{Category, ExternalRef, Finding, Severity, Source};
use peripheral_core::{Bus, DeviceConnection};
use shellhist_core::HistoryEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
Executed,
Accessed,
Connected,
Searched,
Typed,
HistoryTampered,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Subject {
Command(String),
File(String),
Folder(String),
Device {
id: String,
volume_serial: Option<u32>,
},
Query(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SourceKind {
ShellHistory,
PeripheralDevice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserActivity {
pub timestamp: Option<i64>,
pub actor: Option<String>,
pub action: Action,
pub subject: Subject,
pub source: SourceKind,
pub detail: String,
}
pub trait ActivitySource {
fn activities(&self) -> Vec<UserActivity>;
}
fn is_history_tamper(cmd: &str) -> bool {
let c = cmd.to_ascii_lowercase();
let c = c.trim();
c.contains("unset histfile")
|| c.contains("histfile=/dev/null")
|| c.contains("histsize=0")
|| c.contains("histfilesize=0")
|| (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
|| c.contains("history -c")
|| (c.contains("clear-history"))
|| (c.contains("remove-item") && c.contains("consolehost_history"))
|| (c.contains("rm ") && c.contains(".bash_history"))
|| (c.contains("rm ") && c.contains(".zsh_history"))
|| (c.starts_with("> ") && c.contains("history"))
}
pub struct ShellHistorySource<'a> {
entries: &'a [HistoryEntry],
actor: Option<String>,
}
impl<'a> ShellHistorySource<'a> {
#[must_use]
pub fn new(entries: &'a [HistoryEntry]) -> Self {
Self {
entries,
actor: None,
}
}
#[must_use]
pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
Self {
entries,
actor: Some(actor.into()),
}
}
}
impl ActivitySource for ShellHistorySource<'_> {
fn activities(&self) -> Vec<UserActivity> {
from_shell_history(self.entries, self.actor.as_deref())
}
}
#[must_use]
pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
entries
.iter()
.map(|e| {
let action = if is_history_tamper(&e.command) {
Action::HistoryTampered
} else {
Action::Executed
};
UserActivity {
timestamp: e.timestamp,
actor: actor.map(ToString::to_string),
action,
subject: Subject::Command(e.command.clone()),
source: SourceKind::ShellHistory,
detail: e.command.clone(),
}
})
.collect()
}
pub struct DeviceSource<'a> {
connections: &'a [DeviceConnection],
}
impl<'a> DeviceSource<'a> {
#[must_use]
pub fn new(connections: &'a [DeviceConnection]) -> Self {
Self { connections }
}
}
impl ActivitySource for DeviceSource<'_> {
fn activities(&self) -> Vec<UserActivity> {
from_device_connections(self.connections)
}
}
#[must_use]
pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
connections
.iter()
.map(|c| {
let timestamp = c
.first_install
.or(c.last_arrival)
.or(c.last_install)
.map(|s| s.value);
UserActivity {
timestamp,
actor: None,
action: Action::Connected,
subject: Subject::Device {
id: c.device_instance_id.clone(),
volume_serial: c.volume_serial,
},
source: SourceKind::PeripheralDevice,
detail: c.device_instance_id.clone(),
}
})
.collect()
}
#[must_use]
pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
events
}
pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
#[must_use]
pub fn source(scope: impl Into<String>) -> Source {
Source {
analyzer: "useract-forensic".to_string(),
scope: scope.into(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
#[must_use]
pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
let mut pairs = Vec::new();
for (di, dev) in events.iter().enumerate() {
let Subject::Device {
volume_serial: Some(dev_serial),
..
} = &dev.subject
else {
continue;
};
for (fi, file) in events.iter().enumerate() {
let is_file = matches!(file.subject, Subject::File(_) | Subject::Folder(_));
if is_file && file_volume_serial(file) == Some(*dev_serial) {
pairs.push((di, fi));
}
}
}
pairs
}
fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
for tok in activity.detail.split_whitespace() {
if let Some(rest) = tok.strip_prefix("vol:") {
if let Ok(serial) = rest.parse::<u32>() {
return Some(serial);
}
}
}
None
}
#[must_use]
pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
audit_with(events, &source("host"))
}
#[must_use]
pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
let mut findings = Vec::new();
let media_windows: Vec<(i64, &str)> = events
.iter()
.filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
(Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
Some((ts, id.as_str()))
}
_ => None,
})
.collect();
for event in events {
if event.action == Action::HistoryTampered {
findings.push(history_tampered_finding(event, src));
continue;
}
if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
(event.action, event.timestamp, &event.subject)
{
if let Some((win_ts, dev_id)) = media_windows
.iter()
.find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
{
findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
}
}
}
findings
}
fn is_mass_storage_id(instance_id: &str) -> bool {
let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
Bus::from_enumerator(enumerator).is_mass_storage()
}
fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
let cmd = match &event.subject {
Subject::Command(c) => c.as_str(),
_ => event.detail.as_str(),
};
Finding::observation(
Severity::Medium,
Category::Concealment,
"USERACT-HISTORY-TAMPERED",
)
.source(src.clone())
.note(format!(
"user activity {cmd:?} disables or clears the activity record; consistent with \
anti-forensic history tampering (MITRE T1070.003)"
))
.evidence("command", cmd.to_string())
.external_ref(ExternalRef::mitre_attack("T1070.003"))
.build()
}
fn exec_during_media_finding(
cmd: &str,
cmd_ts: i64,
dev_ts: i64,
dev_id: &str,
src: &Source,
) -> Finding {
Finding::observation(
Severity::Low,
Category::Threat,
"USERACT-EXEC-DURING-REMOVABLE-MEDIA",
)
.source(src.clone())
.note(format!(
"the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
device {dev_id:?} being connected; consistent with activity involving external media \
(MITRE T1052 / T1091)"
))
.evidence("command", cmd.to_string())
.evidence("device", dev_id.to_string())
.evidence("command_epoch", cmd_ts.to_string())
.evidence("device_epoch", dev_ts.to_string())
.external_ref(ExternalRef::mitre_attack("T1052"))
.external_ref(ExternalRef::mitre_attack("T1091"))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
use peripheral_core::{Bus, Provenance, Stamp};
use shellhist_core::{HistoryEntry, Shell};
fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
HistoryEntry {
shell: Shell::Bash,
command: cmd.to_string(),
timestamp: ts,
elapsed: None,
paths: Vec::new(),
}
}
fn device(
instance_id: &str,
bus: Bus,
first_install: Option<i64>,
vol: Option<u32>,
) -> DeviceConnection {
DeviceConnection {
bus,
device_class_guid: None,
vid: None,
pid: None,
device_serial: None,
serial_is_os_generated: false,
friendly_name: None,
device_instance_id: instance_id.to_string(),
first_install: first_install.map(Stamp::authoritative),
last_install: None,
last_arrival: None,
last_removal: None,
parent_id_prefix: None,
volume_guid: None,
drive_letter: None,
volume_serial: vol,
disk_signature: None,
dma_capable: bus.is_dma_capable(),
mitre: Vec::new(),
source: Provenance {
file: "setupapi.dev.log".to_string(),
line: 1,
},
}
}
#[test]
fn shell_command_becomes_executed_activity() {
let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
let acts = from_shell_history(&entries, None);
assert_eq!(acts.len(), 1);
assert_eq!(acts[0].action, Action::Executed);
assert_eq!(acts[0].source, SourceKind::ShellHistory);
assert_eq!(acts[0].timestamp, Some(1_700_000_000));
assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
assert_eq!(acts[0].actor, None);
}
#[test]
fn shell_actor_is_carried_when_known() {
let entries = [entry("whoami", None)];
let acts = from_shell_history(&entries, Some("alice"));
assert_eq!(acts[0].actor.as_deref(), Some("alice"));
}
#[test]
fn history_clearing_command_becomes_tampered() {
for cmd in [
"unset HISTFILE",
"history -c",
"export HISTFILE=/dev/null",
"Clear-History",
"rm ~/.bash_history",
] {
let entries = [entry(cmd, Some(1))];
let acts = from_shell_history(&entries, None);
assert_eq!(acts[0].action, Action::HistoryTampered);
}
}
#[test]
fn benign_command_is_not_tampered() {
let entries = [entry("git log --oneline", Some(1))];
let acts = from_shell_history(&entries, None);
assert_eq!(acts[0].action, Action::Executed);
}
#[test]
fn device_becomes_connected_with_volume_serial() {
let conns = [device(
"USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
Bus::Usb,
Some(1_700_000_500),
Some(0xDEAD_BEEF),
)];
let acts = from_device_connections(&conns);
assert_eq!(acts.len(), 1);
assert_eq!(acts[0].action, Action::Connected);
assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
assert_eq!(acts[0].timestamp, Some(1_700_000_500));
assert_eq!(
acts[0].subject,
Subject::Device {
id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
volume_serial: Some(0xDEAD_BEEF),
}
);
}
#[test]
fn device_timestamp_falls_back_through_stamps() {
let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
conn.last_arrival = Some(Stamp::inferred(42));
let acts = from_device_connections(&[conn]);
assert_eq!(acts[0].timestamp, Some(42));
}
#[test]
fn device_without_any_stamp_has_no_timestamp() {
let conn = device("USB\\VID_0781", Bus::Usb, None, None);
let acts = from_device_connections(&[conn]);
assert_eq!(acts[0].timestamp, None);
}
#[test]
fn timeline_merges_and_sorts_by_timestamp() {
let entries = [entry("late", Some(300)), entry("early", Some(100))];
let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
let shell = ShellHistorySource::new(&entries);
let devices = DeviceSource::new(&conns);
let tl = build_timeline(&[&shell, &devices]);
let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
}
#[test]
fn timeline_orders_untimestamped_events_last_and_stably() {
let entries = [
entry("no_ts_a", None),
entry("ts", Some(50)),
entry("no_ts_b", None),
];
let shell = ShellHistorySource::new(&entries);
let tl = build_timeline(&[&shell]);
assert_eq!(tl[0].timestamp, Some(50));
assert_eq!(tl[1].detail, "no_ts_a");
assert_eq!(tl[2].detail, "no_ts_b");
}
#[test]
fn audit_surfaces_history_tampered() {
let entries = [entry("unset HISTFILE", Some(10))];
let acts = from_shell_history(&entries, None);
let findings = audit(&acts);
let f = findings
.iter()
.find(|f| f.code == "USERACT-HISTORY-TAMPERED")
.expect("history-tampered finding must fire");
assert_eq!(f.severity, Some(Severity::Medium));
assert_eq!(f.category, Category::Concealment);
}
#[test]
fn audit_fires_exec_during_removable_media_within_window() {
let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
let shell = ShellHistorySource::new(&entries);
let devices = DeviceSource::new(&conns);
let tl = build_timeline(&[&shell, &devices]);
let findings = audit(&tl);
assert!(findings
.iter()
.any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
}
#[test]
fn audit_does_not_fire_outside_window() {
let entries = [entry("ls", Some(1_000))];
let conns = [device(
"USBSTOR\\Disk",
Bus::Usb,
Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
None,
)];
let shell = ShellHistorySource::new(&entries);
let devices = DeviceSource::new(&conns);
let tl = build_timeline(&[&shell, &devices]);
let findings = audit(&tl);
assert!(findings
.iter()
.all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
}
#[test]
fn audit_does_not_fire_for_non_mass_storage_device() {
let entries = [entry("ls", Some(1_000))];
let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
let shell = ShellHistorySource::new(&entries);
let devices = DeviceSource::new(&conns);
let tl = build_timeline(&[&shell, &devices]);
let findings = audit(&tl);
assert!(findings
.iter()
.all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
}
#[test]
fn audit_with_custom_source_stamps_scope() {
let entries = [entry("history -c", Some(1))];
let acts = from_shell_history(&entries, None);
let findings = audit_with(&acts, &source("CASE-001/host-7"));
let f = &findings[0];
assert_eq!(f.source.scope, "CASE-001/host-7");
assert_eq!(f.source.analyzer, "useract-forensic");
}
#[test]
fn findings_are_hedged_observations_never_verdicts() {
let entries = [
entry("unset HISTFILE", Some(1_000)),
entry("cp x /media/usb", Some(1_010)),
];
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
let shell = ShellHistorySource::new(&entries);
let devices = DeviceSource::new(&conns);
let tl = build_timeline(&[&shell, &devices]);
let findings = audit(&tl);
assert!(!findings.is_empty());
for f in &findings {
let note = f.note.to_ascii_lowercase();
assert!(!note.contains("proves"));
assert!(!note.contains("confirms"));
assert!(!note.contains("definitely"));
assert!(note.contains("consistent with"));
}
}
#[test]
fn volume_serial_join_is_empty_for_v01_sources() {
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
let acts = from_device_connections(&conns);
assert!(device_file_volume_joins(&acts).is_empty());
}
#[test]
fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
let mut acts = from_device_connections(&conns);
acts.push(UserActivity {
timestamp: Some(2),
actor: None,
action: Action::Accessed,
subject: Subject::File("\\\\?\\E:\\secret.docx".to_string()),
source: SourceKind::PeripheralDevice, detail: "opened E:\\secret.docx vol:4660".to_string(), });
let joins = device_file_volume_joins(&acts);
assert_eq!(joins, vec![(0, 1)]);
}
#[test]
fn volume_serial_join_ignores_mismatched_serials() {
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
let mut acts = from_device_connections(&conns);
acts.push(UserActivity {
timestamp: Some(2),
actor: None,
action: Action::Accessed,
subject: Subject::File("x".to_string()),
source: SourceKind::PeripheralDevice,
detail: "vol:9999".to_string(),
});
assert!(device_file_volume_joins(&acts).is_empty());
}
#[test]
fn volume_serial_join_skips_files_without_a_volume_token() {
let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
let mut acts = from_device_connections(&conns);
acts.push(UserActivity {
timestamp: Some(2),
actor: None,
action: Action::Accessed,
subject: Subject::Folder("E:\\photos".to_string()),
source: SourceKind::PeripheralDevice,
detail: "opened folder with no serial hint".to_string(),
});
acts.push(UserActivity {
timestamp: Some(3),
actor: None,
action: Action::Accessed,
subject: Subject::File("E:\\x".to_string()),
source: SourceKind::PeripheralDevice,
detail: "vol:notanumber".to_string(),
});
assert!(device_file_volume_joins(&acts).is_empty());
}
#[test]
fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
let act = UserActivity {
timestamp: Some(1),
actor: None,
action: Action::HistoryTampered,
subject: Subject::File("ConsoleHost_history.txt".to_string()),
source: SourceKind::ShellHistory,
detail: "Remove-Item ConsoleHost_history.txt".to_string(),
};
let findings = audit(&[act]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
assert!(findings[0]
.note
.contains("Remove-Item ConsoleHost_history.txt"));
}
#[test]
fn is_mass_storage_id_classifies_bare_and_separated_ids() {
assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
assert!(is_mass_storage_id("USBSTOR"));
assert!(!is_mass_storage_id("BTHENUM\\Dev"));
assert!(!is_mass_storage_id(""));
}
#[test]
fn activitysource_trait_dispatches() {
let entries = [entry("ls", Some(1))];
let s = ShellHistorySource::for_actor(&entries, "bob");
let acts: Vec<UserActivity> = s.activities();
assert_eq!(acts[0].actor.as_deref(), Some("bob"));
}
}