peripheral_core/lib.rs
1//! `peripheral-core` — external-device (peripheral) connection forensic reader.
2//!
3//! Parses Windows `setupapi.dev.log` device-installation logs into a uniform
4//! [`DeviceConnection`] stream: bus-classified, with each timestamp tagged
5//! authoritative-vs-inferred and the USB iSerial kept distinct from any volume
6//! serial. The input is attacker-controllable evidence — parsing is lenient
7//! (lossy UTF-8), bounds-checked, and never panics. No `unsafe`.
8//!
9//! Findings (DMA-capable device, mass-storage, HID/BadUSB, OS-generated serial)
10//! live in the sibling `peripheral-forensic` crate; this crate only decodes.
11//!
12//! ## v0.2 enrichment (not in this release)
13//!
14//! The richest source — the Windows registry `SYSTEM\CurrentControlSet\Enum\`
15//! keys (USBSTOR/USB), `MountedDevices`, and the device-property `0066`/`0067`
16//! Last-Arrival/Last-Removal `FILETIME`s — plus EVTX device events require the
17//! (unpublished) `winreg-core` and `winevt-forensic` crates. They are deferred
18//! to v0.2; v0.1 is scoped to the self-contained `setupapi.dev.log` source.
19
20#![forbid(unsafe_code)]
21
22pub mod linux_syslog;
23pub mod mounted_volumes;
24pub mod mountpoints2;
25pub mod registry;
26pub mod setupapi;
27pub mod volume_info;
28
29/// The physical/logical bus a peripheral attached through.
30///
31/// The variant drives the DMA-capability and storage-class threat lenses
32/// downstream (see [`DeviceConnection::dma_capable`]).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum Bus {
35 /// USB (host-controller mediated; not directly DMA-capable as mass storage).
36 Usb,
37 /// Media Transfer Protocol (phones/cameras) — surfaced via `WpdBusEnumRoot`.
38 Mtp,
39 /// IEEE 1394 FireWire — bus-mastering DMA.
40 FireWire,
41 /// Thunderbolt — PCIe tunnelled, bus-mastering DMA.
42 Thunderbolt,
43 /// PCI Express — bus-mastering DMA.
44 Pcie,
45 /// External SATA — SATA/storage transport, explicitly NOT DMA.
46 Esata,
47 /// SD/MMC card.
48 SdMmc,
49 /// Bluetooth (typically HID/wireless).
50 Bluetooth,
51 /// ExpressCard — PCIe-backed, bus-mastering DMA.
52 ExpressCard,
53 /// SCSI / SAS storage transport.
54 ScsiSas,
55 /// NVMe storage.
56 Nvme,
57 /// Bus could not be determined from the enumerator.
58 Unknown,
59}
60
61impl Bus {
62 /// Classify a bus from a setupapi/instance-id **enumerator** prefix — the
63 /// leading token of a device instance id (`USBSTOR`, `USB`, `1394`, `PCI`,
64 /// `SCSI`, `SD`, `WpdBusEnumRoot`, …), matched case-insensitively.
65 ///
66 /// Returns [`Bus::Unknown`] for an unrecognized or empty enumerator; the
67 /// caller never gets a panic.
68 #[must_use]
69 pub fn from_enumerator(enumerator: &str) -> Self {
70 let e = enumerator.trim().to_ascii_uppercase();
71 match e.as_str() {
72 "USBSTOR" | "USB" => Self::Usb,
73 "1394" => Self::FireWire,
74 "THUNDERBOLT" => Self::Thunderbolt,
75 "PCI" | "PCIE" => Self::Pcie,
76 "SCSI" | "SAS" => Self::ScsiSas,
77 "NVME" => Self::Nvme,
78 "SD" | "MMC" | "SDBUS" => Self::SdMmc,
79 "ESATA" => Self::Esata,
80 "BTHENUM" | "BTHLE" | "BLUETOOTH" => Self::Bluetooth,
81 "EXPRESSCARD" => Self::ExpressCard,
82 "WPDBUSENUMROOT" | "MTP" => Self::Mtp,
83 _ => Self::Unknown,
84 }
85 }
86
87 /// Whether this bus can perform **bus-mastering DMA**, the property that
88 /// makes a device a direct-memory-access attack surface (MITRE T1200).
89 ///
90 /// DMA-capable: FireWire, Thunderbolt, PCIe, ExpressCard. Storage-class
91 /// transports (USB mass storage, eSATA, SD/MMC, SCSI/SAS, NVMe) and
92 /// HID/wireless transports (USB-HID, Bluetooth) are NOT DMA in this model.
93 ///
94 /// Caveat: SD-Express tunnels PCIe and *can* be DMA-capable; this v0.1
95 /// classifier treats bare `SD` as the legacy non-DMA SD/MMC bus, the common
96 /// case. Distinguishing SD-Express needs the device-capability bits that the
97 /// registry/EVTX v0.2 source carries.
98 #[must_use]
99 pub fn is_dma_capable(self) -> bool {
100 matches!(
101 self,
102 Self::FireWire | Self::Thunderbolt | Self::Pcie | Self::ExpressCard
103 )
104 }
105
106 /// Whether this bus is a removable mass-storage transport (the
107 /// data-exfiltration / autorun lens, MITRE T1052.001 / T1091).
108 #[must_use]
109 pub fn is_mass_storage(self) -> bool {
110 matches!(
111 self,
112 Self::Usb | Self::Esata | Self::SdMmc | Self::ScsiSas | Self::Nvme
113 )
114 }
115}
116
117/// How much trust a timestamp carries.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub enum Confidence {
120 /// Directly recorded by the source as the stated event
121 /// (e.g. the setupapi section-header install time → first-seen).
122 Authoritative,
123 /// Derived/undocumented — the value's meaning is inferred, not stated by the
124 /// source (e.g. the registry `0066`/`0067` Last-Arrival/Last-Removal
125 /// device-property `FILETIME`s, which are undocumented).
126 Inferred,
127}
128
129/// A timestamp tagged with its evidentiary confidence.
130///
131/// Pairing the value with its [`Confidence`] in the type makes the
132/// authoritative-vs-inferred distinction impossible to drop on the floor: a
133/// consumer cannot read `value` without also seeing how trustworthy it is.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub struct Stamp {
136 /// Unix epoch seconds.
137 pub value: i64,
138 /// How the value should be trusted.
139 pub confidence: Confidence,
140}
141
142impl Stamp {
143 /// An authoritative (source-stated) timestamp.
144 #[must_use]
145 pub fn authoritative(value: i64) -> Self {
146 Self {
147 value,
148 confidence: Confidence::Authoritative,
149 }
150 }
151
152 /// An inferred (derived/undocumented) timestamp.
153 #[must_use]
154 pub fn inferred(value: i64) -> Self {
155 Self {
156 value,
157 confidence: Confidence::Inferred,
158 }
159 }
160}
161
162/// A MITRE ATT&CK technique a connection is *consistent with* — never a verdict.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub struct MitreRef(pub &'static str);
165
166/// One external-device connection, normalized across sources.
167///
168/// The forensic cautions are baked into the type, not just the docs:
169/// - [`device_serial`](Self::device_serial) is the **USB iSerial** and is a
170/// distinct field from [`volume_serial`](Self::volume_serial) (a filesystem
171/// volume serial), so the two can never be conflated.
172/// - [`serial_is_os_generated`](Self::serial_is_os_generated) records that the
173/// device had no real iSerial (Windows synthesized one), weakening attribution.
174/// - Each timestamp is a [`Stamp`] carrying its authoritative-vs-inferred
175/// [`Confidence`].
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct DeviceConnection {
178 // ── Identity ────────────────────────────────────────────────────────────
179 /// The classified bus.
180 pub bus: Bus,
181 /// Device setup-class GUID, when known.
182 pub device_class_guid: Option<String>,
183 /// USB vendor id (`VID_xxxx`).
184 pub vid: Option<u16>,
185 /// USB product id (`PID_xxxx`).
186 pub pid: Option<u16>,
187 /// The **USB iSerial** — the device-unique serial reported by the device.
188 /// DISTINCT from any [`volume_serial`](Self::volume_serial).
189 pub device_serial: Option<String>,
190 /// `true` when the instance-id serial was synthesized by Windows (the
191 /// serial's 2nd character is `&`) — the device exposed no real iSerial, so
192 /// attribution is weaker.
193 pub serial_is_os_generated: bool,
194 /// Human-readable friendly name, when present.
195 pub friendly_name: Option<String>,
196 /// The full device instance id (e.g.
197 /// `USB\VID_0781&PID_5583\1234567890AB`) — the primary key.
198 pub device_instance_id: String,
199
200 // ── Timestamps (each tagged authoritative-vs-inferred) ───────────────────
201 /// First-seen / first-install — authoritative when from the setupapi
202 /// section header.
203 pub first_install: Option<Stamp>,
204 /// Last install/driver event.
205 pub last_install: Option<Stamp>,
206 /// Last arrival (connect). INFERRED — derived from the undocumented registry
207 /// `0066` device property (v0.2).
208 pub last_arrival: Option<Stamp>,
209 /// Last removal (disconnect). INFERRED — derived from the undocumented
210 /// registry `0067` device property (v0.2).
211 pub last_removal: Option<Stamp>,
212
213 // ── Correlation join keys (volume_serial kept DISTINCT from device_serial) ─
214 /// `ParentIdPrefix` — joins the storage device to its volume.
215 pub parent_id_prefix: Option<String>,
216 /// Volume GUID (`\\?\Volume{...}`).
217 pub volume_guid: Option<String>,
218 /// Mounted drive letter.
219 pub drive_letter: Option<char>,
220 /// Filesystem **volume** serial (NTFS/FAT) — DISTINCT from the device's
221 /// USB [`device_serial`](Self::device_serial).
222 pub volume_serial: Option<u32>,
223 /// MBR disk signature.
224 pub disk_signature: Option<u32>,
225
226 // ── Threat lens ──────────────────────────────────────────────────────────
227 /// Whether the bus is bus-mastering DMA-capable (see [`Bus::is_dma_capable`]).
228 pub dma_capable: bool,
229 /// MITRE ATT&CK techniques this connection is *consistent with*.
230 pub mitre: Vec<MitreRef>,
231
232 // ── Provenance ───────────────────────────────────────────────────────────
233 /// Where this record came from (source file + 1-based line).
234 pub source: Provenance,
235}
236
237/// Where a [`DeviceConnection`] was decoded from.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct Provenance {
240 /// The source file (e.g. `setupapi.dev.log`, or the hive name `SYSTEM`).
241 pub file: String,
242 /// 1-based line number of the section header the record came from, for
243 /// line-oriented sources (`setupapi.dev.log`). `0` when the source is not
244 /// line-oriented (e.g. a registry hive — see [`key_path`](Self::key_path)).
245 pub line: usize,
246 /// The full registry key path the record was decoded from, for hive sources
247 /// (e.g. `ControlSet001\Enum\SCSI\Disk&Ven_…\5&22be343f&0&000000`). `None` for
248 /// line-oriented sources.
249 pub key_path: Option<String>,
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn usb_enumerators_classify_as_usb() {
258 assert_eq!(Bus::from_enumerator("USBSTOR"), Bus::Usb);
259 assert_eq!(Bus::from_enumerator("USB"), Bus::Usb);
260 assert_eq!(Bus::from_enumerator("usbstor"), Bus::Usb); // case-insensitive
261 }
262
263 #[test]
264 fn bus_specific_enumerators_classify() {
265 assert_eq!(Bus::from_enumerator("1394"), Bus::FireWire);
266 assert_eq!(Bus::from_enumerator("SCSI"), Bus::ScsiSas);
267 assert_eq!(Bus::from_enumerator("PCI"), Bus::Pcie);
268 assert_eq!(Bus::from_enumerator("SD"), Bus::SdMmc);
269 assert_eq!(Bus::from_enumerator("WpdBusEnumRoot"), Bus::Mtp);
270 assert_eq!(Bus::from_enumerator("THUNDERBOLT"), Bus::Thunderbolt);
271 assert_eq!(Bus::from_enumerator("ESATA"), Bus::Esata);
272 assert_eq!(Bus::from_enumerator("EXPRESSCARD"), Bus::ExpressCard);
273 assert_eq!(Bus::from_enumerator("BTHENUM"), Bus::Bluetooth);
274 assert_eq!(Bus::from_enumerator("NVME"), Bus::Nvme);
275 }
276
277 #[test]
278 fn unknown_enumerator_is_unknown_never_panics() {
279 assert_eq!(Bus::from_enumerator("HID"), Bus::Unknown);
280 assert_eq!(Bus::from_enumerator(""), Bus::Unknown);
281 assert_eq!(Bus::from_enumerator(" "), Bus::Unknown);
282 }
283
284 #[test]
285 fn dma_capable_is_exactly_firewire_thunderbolt_pcie_expresscard() {
286 for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Pcie, Bus::ExpressCard] {
287 assert!(b.is_dma_capable(), "{b:?} must be DMA-capable");
288 }
289 // Storage-only transports are explicitly NOT DMA (eSATA is SATA/storage).
290 for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
291 assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
292 }
293 // HID/wireless transports are not DMA either.
294 for b in [Bus::Bluetooth, Bus::Mtp, Bus::Unknown] {
295 assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
296 }
297 }
298
299 #[test]
300 fn mass_storage_classes() {
301 for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
302 assert!(b.is_mass_storage(), "{b:?} should be mass storage");
303 }
304 for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Bluetooth, Bus::Mtp] {
305 assert!(!b.is_mass_storage(), "{b:?} should not be mass storage");
306 }
307 }
308
309 #[test]
310 fn stamp_carries_confidence() {
311 assert_eq!(
312 Stamp::authoritative(10).confidence,
313 Confidence::Authoritative
314 );
315 assert_eq!(Stamp::inferred(10).confidence, Confidence::Inferred);
316 }
317}