use peripheral_core::emdmgmt::{parse_emdmgmt, EmdVolume};
use peripheral_core::linux_syslog::parse_linux_syslog;
use peripheral_core::mounted_volumes::{parse_mounted_volumes, MountedVolume};
use peripheral_core::mountpoints2::{parse_mountpoints2, UserMount};
use peripheral_core::registry::parse_registry;
use peripheral_core::setupapi::parse_setupapi;
use peripheral_core::volume_info::{parse_volume_info_cache, VolumeLabel};
use std::process::ExitCode;
use usb_forensic::{
analyse_device_image, audit, kernel_pnp_events, parse_boot_sectors, parse_ipod_plist,
parse_system_profiler, parse_unified_log, to_jsonl, AppleDevice, AppleIPodSource, DeviceImage,
DeviceImageSource, EmdMgmtSource, HistorySource, JumpListArtifact, JumpListSource,
KernelPnpEvent, KernelPnpSource, LnkArtifact, LnkSource, MacUnifiedLogSource, MacUsbDevice,
MacUsbSource, MountPoints2Source, PartitionDiagSource, PeripheralSource, SourceKind,
UsbEnumeration, VolumeCacheSource,
};
use winevt_extract::{partition_diag, PartitionDiagEvent};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "-V" || a == "--version") {
println!("usb4n6 {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
let mode = if args.iter().any(|a| a == "--pdf") {
Output::Pdf
} else if args.iter().any(|a| a == "--docx") {
Output::Docx
} else if args.iter().any(|a| a == "--report") {
Output::Report
} else if args.iter().any(|a| a == "--table") {
Output::Table
} else if args.iter().any(|a| a == "--timeline") {
Output::Timeline
} else if args.iter().any(|a| a == "--files") {
Output::Files
} else if args.iter().any(|a| a == "--export-mbr") {
Output::ExportMbr
} else {
Output::Jsonl
};
let tz_offset = args.iter().find_map(|a| {
a.strip_prefix("--tz-offset=")
.and_then(|v| v.parse::<i64>().ok())
});
let year = args.iter().find_map(|a| {
a.strip_prefix("--year=")
.and_then(|v| v.parse::<i64>().ok())
});
let paths: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
if paths.is_empty() {
eprintln!(
"usage: usb4n6 [--table|--timeline|--files|--export-mbr|--report|--docx|--pdf] [--tz-offset=<secs>] \
[--year=<YYYY>] <file>... \
(setupapi.dev.log/SYSTEM hive/.lnk/jumplist/.evtx/Linux syslog; -V)"
);
return ExitCode::FAILURE;
}
run(&paths, mode, tz_offset, year)
}
#[derive(Clone, Copy)]
enum Output {
Jsonl,
Table,
Timeline,
Files,
ExportMbr,
Report,
Docx,
Pdf,
}
fn is_shell_link(bytes: &[u8]) -> bool {
bytes.get(..4) == Some(&[0x4C, 0x00, 0x00, 0x00])
}
fn is_compound_file(bytes: &[u8]) -> bool {
bytes.get(..8) == Some(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])
}
fn is_registry_hive(bytes: &[u8]) -> bool {
bytes.get(..4) == Some(b"regf")
}
fn is_evtx(bytes: &[u8]) -> bool {
bytes.get(..8) == Some(b"ElfFile\0")
}
fn parse_kernel_pnp(path: &str) -> Vec<KernelPnpEvent> {
let Ok(mut parser) = evtx::EvtxParser::from_path(path) else {
return Vec::new();
};
kernel_pnp_events(
parser
.records_json_value()
.filter_map(Result::ok)
.map(|r| r.data),
)
}
fn is_disk_image(bytes: &[u8]) -> bool {
bytes.get(0x1FE..0x200) == Some(&[0x55, 0xAA])
}
fn is_e01(bytes: &[u8]) -> bool {
bytes.get(..8) == Some(b"EVF\x09\x0d\x0a\xff\x00")
}
fn parse_e01_device_image(path: &str) -> Option<DeviceImage> {
let mut reader = ewf::EwfReader::open(path).ok()?;
let size = reader.total_size();
analyse_device_image(&mut reader, size)
}
fn is_plist(bytes: &[u8]) -> bool {
bytes.get(..8) == Some(b"bplist00")
|| String::from_utf8_lossy(bytes.get(..512).unwrap_or(bytes)).contains("<plist")
}
fn is_system_profiler_usb(bytes: &[u8]) -> bool {
String::from_utf8_lossy(bytes.get(..512).unwrap_or(bytes)).contains("SPUSBDataType")
}
fn is_unified_log(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(bytes.get(..512).unwrap_or(bytes));
head.trim_start().starts_with('[') && head.contains("logEvent")
}
fn looks_like_linux_syslog(text: &str) -> bool {
text.contains("New USB device found")
}
fn write_binary(bytes: &[u8], label: &str) -> bool {
use std::io::Write as _;
match std::io::stdout().write_all(bytes) {
Ok(()) => true,
Err(err) => {
eprintln!("usb4n6: cannot write {label}: {err}");
false
}
}
}
#[derive(Default)]
struct Ingested {
setupapi: Vec<peripheral_core::DeviceConnection>,
registry: Vec<peripheral_core::DeviceConnection>,
linux: Vec<peripheral_core::DeviceConnection>,
lnk: Vec<LnkArtifact>,
jumplists: Vec<JumpListArtifact>,
partition_diag: Vec<PartitionDiagEvent>,
kernel_pnp: Vec<KernelPnpEvent>,
volume_labels: Vec<VolumeLabel>,
emd_volumes: Vec<EmdVolume>,
device_images: Vec<(DeviceImage, String)>,
apple_devices: Vec<(Vec<AppleDevice>, String)>,
mac_usb: Vec<(Vec<MacUsbDevice>, String)>,
mac_log: Vec<(Vec<UsbEnumeration>, String)>,
mounted_volumes: Vec<MountedVolume>,
user_mounts: Vec<UserMount>,
}
impl Ingested {
fn record_count(&self) -> usize {
self.setupapi.len()
+ self.registry.len()
+ self.linux.len()
+ self.lnk.len()
+ self.jumplists.len()
+ self.partition_diag.len()
+ self.kernel_pnp.len()
+ self.volume_labels.len()
+ self.user_mounts.len()
+ self.emd_volumes.len()
+ self.device_images.len()
+ self
.apple_devices
.iter()
.map(|(d, _)| d.len())
.sum::<usize>()
+ self.mac_usb.iter().map(|(d, _)| d.len()).sum::<usize>()
+ self.mac_log.iter().map(|(d, _)| d.len()).sum::<usize>()
}
}
fn ingest(paths: &[&String], year: Option<i64>) -> Option<Ingested> {
let mut g = Ingested::default();
for path in paths {
let bytes = match std::fs::read(path.as_str()) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("usb4n6: cannot read {path}: {err}");
return None;
}
};
if is_compound_file(&bytes) {
match lnk_core::parse_automatic_destinations(&bytes, Some(path)) {
Some(list) => g.jumplists.push(JumpListArtifact {
source_path: (*path).clone(),
list,
}),
None => eprintln!("usb4n6: {path}: not a valid jump list, skipping"),
}
} else if is_shell_link(&bytes) {
match lnk_core::parse_shell_link(&bytes) {
Some(link) => g.lnk.push(LnkArtifact {
source_path: (*path).clone(),
link,
}),
None => eprintln!("usb4n6: {path}: not a valid Shell Link, skipping"),
}
} else if is_registry_hive(&bytes) {
match winreg_core::hive::Hive::from_bytes(bytes) {
Ok(hive) => {
g.registry.extend(parse_registry(&hive, path));
g.volume_labels.extend(parse_volume_info_cache(&hive, path));
g.mounted_volumes.extend(parse_mounted_volumes(&hive, path));
g.user_mounts.extend(parse_mountpoints2(&hive, path));
g.emd_volumes.extend(parse_emdmgmt(&hive, path));
}
Err(err) => eprintln!("usb4n6: {path}: not a valid registry hive: {err}"),
}
} else if is_unified_log(&bytes) {
let evs = parse_unified_log(&bytes);
if evs.is_empty() {
eprintln!("usb4n6: {path}: unified log has no USB enumeration events, skipping");
} else {
g.mac_log.push((evs, (*path).clone()));
}
} else if is_system_profiler_usb(&bytes) {
let devs = parse_system_profiler(&bytes);
if devs.is_empty() {
eprintln!("usb4n6: {path}: system_profiler capture has no USB devices, skipping");
} else {
g.mac_usb.push((devs, (*path).clone()));
}
} else if is_plist(&bytes) {
let devs = parse_ipod_plist(&bytes);
if devs.is_empty() {
eprintln!("usb4n6: {path}: plist has no Apple-device history, skipping");
} else {
g.apple_devices.push((devs, (*path).clone()));
}
} else if is_e01(&bytes) {
match parse_e01_device_image(path) {
Some(img) => g.device_images.push((img, (*path).clone())),
None => eprintln!("usb4n6: {path}: E01 has no readable MBR, skipping"),
}
} else if is_disk_image(&bytes) {
match parse_boot_sectors(&bytes) {
Some(img) => g.device_images.push((img, (*path).clone())),
None => eprintln!("usb4n6: {path}: MBR present but unreadable, skipping"),
}
} else if is_evtx(&bytes) {
match partition_diag(std::path::Path::new(path.as_str())) {
Ok(events) => g.partition_diag.extend(events),
Err(err) => eprintln!("usb4n6: {path}: cannot parse event log: {err}"),
}
g.kernel_pnp.extend(parse_kernel_pnp(path));
} else {
let text = String::from_utf8_lossy(&bytes);
if looks_like_linux_syslog(&text) {
let Some(y) = year else {
eprintln!(
"usb4n6: {path}: Linux syslog timestamps are year-less — \
pass --year=<YYYY>"
);
return None;
};
g.linux.extend(parse_linux_syslog(&text, path, y));
} else {
g.setupapi.extend(parse_setupapi(&text, path));
}
}
}
Some(g)
}
fn run(paths: &[&String], mode: Output, tz_offset: Option<i64>, year: Option<i64>) -> ExitCode {
let Some(g) = ingest(paths, year) else {
return ExitCode::FAILURE;
};
let setupapi = PeripheralSource::new(&g.setupapi, SourceKind::SetupApi);
let registry = PeripheralSource::new(&g.registry, SourceKind::Usbstor);
let linux = PeripheralSource::new(&g.linux, SourceKind::LinuxKernelLog);
let lnk = LnkSource::new(&g.lnk);
let jumplist = JumpListSource::new(&g.jumplists);
let partdiag = PartitionDiagSource::new(&g.partition_diag);
let kernelpnp = KernelPnpSource::new(&g.kernel_pnp);
let volcache = VolumeCacheSource::new(&g.volume_labels);
let usermounts = MountPoints2Source::new(&g.user_mounts);
let emd = EmdMgmtSource::new(&g.emd_volumes);
let sources: [&dyn HistorySource; 10] = [
&setupapi,
®istry,
&linux,
&lnk,
&jumplist,
&partdiag,
&kernelpnp,
&volcache,
&usermounts,
&emd,
];
let mut claims: Vec<_> = sources.iter().flat_map(|s| s.claims()).collect();
for (img, loc) in &g.device_images {
claims.extend(DeviceImageSource::new(img, loc.clone()).claims());
}
for (devs, loc) in &g.apple_devices {
claims.extend(AppleIPodSource::new(devs, loc.clone()).claims());
}
for (devs, loc) in &g.mac_usb {
claims.extend(MacUsbSource::new(devs, loc.clone()).claims());
}
for (evs, loc) in &g.mac_log {
claims.extend(MacUnifiedLogSource::new(evs, loc.clone()).claims());
}
if let Some(offset) = tz_offset {
usb_forensic::normalize_local_clocks(&mut claims, offset);
}
let claims = usb_forensic::reconcile_volume_serials(&claims);
let claims = usb_forensic::canonicalize_mounted_volumes(&claims, &g.mounted_volumes);
let histories = usb_forensic::correlate(&claims);
let findings = audit(&histories);
let rendered = match mode {
Output::Jsonl => match to_jsonl(&histories) {
Ok(jsonl) => jsonl,
Err(err) => {
eprintln!("usb4n6: serialization failed: {err}");
return ExitCode::FAILURE;
}
},
Output::Table => usb_forensic::render_table(&histories),
Output::Files => usb_forensic::render_accessed_files(&histories),
Output::ExportMbr => g
.device_images
.iter()
.map(|(img, loc)| usb_forensic::export_mbr_hex(img, loc))
.collect(),
Output::Timeline => {
let events = usb_forensic::super_timeline(&histories);
match usb_forensic::timeline_to_jsonl(&events) {
Ok(jsonl) => jsonl,
Err(err) => {
eprintln!("usb4n6: serialization failed: {err}");
return ExitCode::FAILURE;
}
}
}
Output::Report => usb_forensic::render_report(&histories, &findings),
Output::Docx => {
if !write_binary(&usb_forensic::render_docx(&histories, &findings), "docx") {
return ExitCode::FAILURE;
}
String::new()
}
Output::Pdf => {
if !write_binary(&usb_forensic::render_pdf(&histories, &findings), "pdf") {
return ExitCode::FAILURE;
}
String::new()
}
};
print!("{rendered}");
eprintln!(
"usb4n6: {} device(s) from {} source record(s), {} finding(s)",
histories.len(),
g.record_count(),
findings.len()
);
for finding in &findings {
eprintln!(
" [{:?}] {} — {}",
finding.severity, finding.code, finding.note
);
}
ExitCode::SUCCESS
}