Skip to main content

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 emdmgmt;
23pub mod linux_syslog;
24pub mod mounted_volumes;
25pub mod mountpoints2;
26pub mod registry;
27pub mod setupapi;
28pub mod shellbag;
29pub mod usb_ids;
30pub mod volume_info;
31
32/// The physical/logical bus a peripheral attached through.
33///
34/// The variant drives the DMA-capability and storage-class threat lenses
35/// downstream (see [`DeviceConnection::dma_capable`]).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Bus {
38    /// USB (host-controller mediated; not directly DMA-capable as mass storage).
39    Usb,
40    /// Media Transfer Protocol (phones/cameras) — surfaced via `WpdBusEnumRoot`.
41    Mtp,
42    /// IEEE 1394 FireWire — bus-mastering DMA.
43    FireWire,
44    /// Thunderbolt — PCIe tunnelled, bus-mastering DMA.
45    Thunderbolt,
46    /// PCI Express — bus-mastering DMA.
47    Pcie,
48    /// External SATA — SATA/storage transport, explicitly NOT DMA.
49    Esata,
50    /// SD/MMC card.
51    SdMmc,
52    /// Bluetooth (typically HID/wireless).
53    Bluetooth,
54    /// ExpressCard — PCIe-backed, bus-mastering DMA.
55    ExpressCard,
56    /// SCSI / SAS storage transport.
57    ScsiSas,
58    /// NVMe storage.
59    Nvme,
60    /// Bus could not be determined from the enumerator.
61    Unknown,
62}
63
64impl Bus {
65    /// Classify a bus from a setupapi/instance-id **enumerator** prefix — the
66    /// leading token of a device instance id (`USBSTOR`, `USB`, `1394`, `PCI`,
67    /// `SCSI`, `SD`, `WpdBusEnumRoot`, …), matched case-insensitively.
68    ///
69    /// Returns [`Bus::Unknown`] for an unrecognized or empty enumerator; the
70    /// caller never gets a panic.
71    #[must_use]
72    pub fn from_enumerator(enumerator: &str) -> Self {
73        let e = enumerator.trim().to_ascii_uppercase();
74        match e.as_str() {
75            "USBSTOR" | "USB" => Self::Usb,
76            "1394" => Self::FireWire,
77            "THUNDERBOLT" => Self::Thunderbolt,
78            "PCI" | "PCIE" => Self::Pcie,
79            "SCSI" | "SAS" => Self::ScsiSas,
80            "NVME" => Self::Nvme,
81            "SD" | "MMC" | "SDBUS" => Self::SdMmc,
82            "ESATA" => Self::Esata,
83            "BTHENUM" | "BTHLE" | "BLUETOOTH" => Self::Bluetooth,
84            "EXPRESSCARD" => Self::ExpressCard,
85            "WPDBUSENUMROOT" | "MTP" => Self::Mtp,
86            _ => Self::Unknown,
87        }
88    }
89
90    /// Whether this bus can perform **bus-mastering DMA**, the property that
91    /// makes a device a direct-memory-access attack surface (MITRE T1200).
92    ///
93    /// DMA-capable: FireWire, Thunderbolt, PCIe, ExpressCard. Storage-class
94    /// transports (USB mass storage, eSATA, SD/MMC, SCSI/SAS, NVMe) and
95    /// HID/wireless transports (USB-HID, Bluetooth) are NOT DMA in this model.
96    ///
97    /// Caveat: SD-Express tunnels PCIe and *can* be DMA-capable; this v0.1
98    /// classifier treats bare `SD` as the legacy non-DMA SD/MMC bus, the common
99    /// case. Distinguishing SD-Express needs the device-capability bits that the
100    /// registry/EVTX v0.2 source carries.
101    #[must_use]
102    pub fn is_dma_capable(self) -> bool {
103        matches!(
104            self,
105            Self::FireWire | Self::Thunderbolt | Self::Pcie | Self::ExpressCard
106        )
107    }
108
109    /// Whether this bus is a removable mass-storage transport (the
110    /// data-exfiltration / autorun lens, MITRE T1052.001 / T1091).
111    #[must_use]
112    pub fn is_mass_storage(self) -> bool {
113        matches!(
114            self,
115            Self::Usb | Self::Esata | Self::SdMmc | Self::ScsiSas | Self::Nvme
116        )
117    }
118}
119
120/// How much trust a timestamp carries.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum Confidence {
123    /// Directly recorded by the source as the stated event
124    /// (e.g. the setupapi section-header install time → first-seen).
125    Authoritative,
126    /// Derived/undocumented — the value's meaning is inferred, not stated by the
127    /// source (e.g. the registry `0066`/`0067` Last-Arrival/Last-Removal
128    /// device-property `FILETIME`s, which are undocumented).
129    Inferred,
130}
131
132/// A timestamp tagged with its evidentiary confidence.
133///
134/// Pairing the value with its [`Confidence`] in the type makes the
135/// authoritative-vs-inferred distinction impossible to drop on the floor: a
136/// consumer cannot read `value` without also seeing how trustworthy it is.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct Stamp {
139    /// Unix epoch seconds.
140    pub value: i64,
141    /// How the value should be trusted.
142    pub confidence: Confidence,
143}
144
145impl Stamp {
146    /// An authoritative (source-stated) timestamp.
147    #[must_use]
148    pub fn authoritative(value: i64) -> Self {
149        Self {
150            value,
151            confidence: Confidence::Authoritative,
152        }
153    }
154
155    /// An inferred (derived/undocumented) timestamp.
156    #[must_use]
157    pub fn inferred(value: i64) -> Self {
158        Self {
159            value,
160            confidence: Confidence::Inferred,
161        }
162    }
163}
164
165/// A MITRE ATT&CK technique a connection is *consistent with* — never a verdict.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
167pub struct MitreRef(pub &'static str);
168
169/// One external-device connection, normalized across sources.
170///
171/// The forensic cautions are baked into the type, not just the docs:
172/// - [`device_serial`](Self::device_serial) is the **USB iSerial** and is a
173///   distinct field from [`volume_serial`](Self::volume_serial) (a filesystem
174///   volume serial), so the two can never be conflated.
175/// - [`serial_is_os_generated`](Self::serial_is_os_generated) records that the
176///   device had no real iSerial (Windows synthesized one), weakening attribution.
177/// - Each timestamp is a [`Stamp`] carrying its authoritative-vs-inferred
178///   [`Confidence`].
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct DeviceConnection {
181    // ── Identity ────────────────────────────────────────────────────────────
182    /// The classified bus.
183    pub bus: Bus,
184    /// Device setup-class GUID, when known.
185    pub device_class_guid: Option<String>,
186    /// USB vendor id (`VID_xxxx`).
187    pub vid: Option<u16>,
188    /// USB product id (`PID_xxxx`).
189    pub pid: Option<u16>,
190    /// The **USB iSerial** — the device-unique serial reported by the device.
191    /// DISTINCT from any [`volume_serial`](Self::volume_serial).
192    pub device_serial: Option<String>,
193    /// `true` when the instance-id serial was synthesized by Windows (the
194    /// serial's 2nd character is `&`) — the device exposed no real iSerial, so
195    /// attribution is weaker.
196    pub serial_is_os_generated: bool,
197    /// Human-readable friendly name, when present.
198    pub friendly_name: Option<String>,
199    /// The full device instance id (e.g.
200    /// `USB\VID_0781&PID_5583\1234567890AB`) — the primary key.
201    pub device_instance_id: String,
202
203    // ── Timestamps (each tagged authoritative-vs-inferred) ───────────────────
204    /// First-seen / first-install — authoritative when from the setupapi
205    /// section header.
206    pub first_install: Option<Stamp>,
207    /// Last install/driver event.
208    pub last_install: Option<Stamp>,
209    /// Last arrival (connect). INFERRED — derived from the undocumented registry
210    /// `0066` device property (v0.2).
211    pub last_arrival: Option<Stamp>,
212    /// Last removal (disconnect). INFERRED — derived from the undocumented
213    /// registry `0067` device property (v0.2).
214    pub last_removal: Option<Stamp>,
215
216    // ── Correlation join keys (volume_serial kept DISTINCT from device_serial) ─
217    /// `ParentIdPrefix` — joins the storage device to its volume.
218    pub parent_id_prefix: Option<String>,
219    /// Volume GUID (`\\?\Volume{...}`).
220    pub volume_guid: Option<String>,
221    /// Mounted drive letter.
222    pub drive_letter: Option<char>,
223    /// Filesystem **volume** serial (NTFS/FAT) — DISTINCT from the device's
224    /// USB [`device_serial`](Self::device_serial).
225    pub volume_serial: Option<u32>,
226    /// MBR disk signature.
227    pub disk_signature: Option<u32>,
228
229    // ── Threat lens ──────────────────────────────────────────────────────────
230    /// Whether the bus is bus-mastering DMA-capable (see [`Bus::is_dma_capable`]).
231    pub dma_capable: bool,
232    /// MITRE ATT&CK techniques this connection is *consistent with*.
233    pub mitre: Vec<MitreRef>,
234
235    // ── Provenance ───────────────────────────────────────────────────────────
236    /// Where this record came from (source file + 1-based line).
237    pub source: Provenance,
238}
239
240impl DeviceConnection {
241    /// Resolve the vendor **name** for this connection's [`vid`](Self::vid) via a
242    /// [`UsbIdDb`](crate::usb_ids::UsbIdDb).
243    ///
244    /// **Non-authoritative enrichment.** The raw numeric `vid` is the evidence;
245    /// this is a lookup convenience and is `None` when the vid is absent or unknown.
246    #[must_use]
247    pub fn vendor_name<'a>(&self, db: &'a crate::usb_ids::UsbIdDb) -> Option<&'a str> {
248        self.vid.and_then(|v| db.vendor_name(v))
249    }
250
251    /// Resolve the product **name** for this connection's `vid`/`pid` via a
252    /// [`UsbIdDb`](crate::usb_ids::UsbIdDb). Non-authoritative (see
253    /// [`vendor_name`](Self::vendor_name)); `None` unless both ids are present and known.
254    #[must_use]
255    pub fn product_name<'a>(&self, db: &'a crate::usb_ids::UsbIdDb) -> Option<&'a str> {
256        match (self.vid, self.pid) {
257            (Some(v), Some(p)) => db.product_name(v, p),
258            _ => None,
259        }
260    }
261}
262
263/// Where a [`DeviceConnection`] was decoded from.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Provenance {
266    /// The source file (e.g. `setupapi.dev.log`, or the hive name `SYSTEM`).
267    pub file: String,
268    /// 1-based line number of the section header the record came from, for
269    /// line-oriented sources (`setupapi.dev.log`). `0` when the source is not
270    /// line-oriented (e.g. a registry hive — see [`key_path`](Self::key_path)).
271    pub line: usize,
272    /// The full registry key path the record was decoded from, for hive sources
273    /// (e.g. `ControlSet001\Enum\SCSI\Disk&Ven_…\5&22be343f&0&000000`). `None` for
274    /// line-oriented sources.
275    pub key_path: Option<String>,
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn usb_enumerators_classify_as_usb() {
284        assert_eq!(Bus::from_enumerator("USBSTOR"), Bus::Usb);
285        assert_eq!(Bus::from_enumerator("USB"), Bus::Usb);
286        assert_eq!(Bus::from_enumerator("usbstor"), Bus::Usb); // case-insensitive
287    }
288
289    #[test]
290    fn bus_specific_enumerators_classify() {
291        assert_eq!(Bus::from_enumerator("1394"), Bus::FireWire);
292        assert_eq!(Bus::from_enumerator("SCSI"), Bus::ScsiSas);
293        assert_eq!(Bus::from_enumerator("PCI"), Bus::Pcie);
294        assert_eq!(Bus::from_enumerator("SD"), Bus::SdMmc);
295        assert_eq!(Bus::from_enumerator("WpdBusEnumRoot"), Bus::Mtp);
296        assert_eq!(Bus::from_enumerator("THUNDERBOLT"), Bus::Thunderbolt);
297        assert_eq!(Bus::from_enumerator("ESATA"), Bus::Esata);
298        assert_eq!(Bus::from_enumerator("EXPRESSCARD"), Bus::ExpressCard);
299        assert_eq!(Bus::from_enumerator("BTHENUM"), Bus::Bluetooth);
300        assert_eq!(Bus::from_enumerator("NVME"), Bus::Nvme);
301    }
302
303    #[test]
304    fn unknown_enumerator_is_unknown_never_panics() {
305        assert_eq!(Bus::from_enumerator("HID"), Bus::Unknown);
306        assert_eq!(Bus::from_enumerator(""), Bus::Unknown);
307        assert_eq!(Bus::from_enumerator("   "), Bus::Unknown);
308    }
309
310    #[test]
311    fn dma_capable_is_exactly_firewire_thunderbolt_pcie_expresscard() {
312        for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Pcie, Bus::ExpressCard] {
313            assert!(b.is_dma_capable(), "{b:?} must be DMA-capable");
314        }
315        // Storage-only transports are explicitly NOT DMA (eSATA is SATA/storage).
316        for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
317            assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
318        }
319        // HID/wireless transports are not DMA either.
320        for b in [Bus::Bluetooth, Bus::Mtp, Bus::Unknown] {
321            assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
322        }
323    }
324
325    #[test]
326    fn mass_storage_classes() {
327        for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
328            assert!(b.is_mass_storage(), "{b:?} should be mass storage");
329        }
330        for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Bluetooth, Bus::Mtp] {
331            assert!(!b.is_mass_storage(), "{b:?} should not be mass storage");
332        }
333    }
334
335    #[test]
336    fn stamp_carries_confidence() {
337        assert_eq!(
338            Stamp::authoritative(10).confidence,
339            Confidence::Authoritative
340        );
341        assert_eq!(Stamp::inferred(10).confidence, Confidence::Inferred);
342    }
343
344    /// A connection carrying only the ids the name lookups read. Every other
345    /// field is empty on purpose: these two methods must not depend on them.
346    fn connection(vid: Option<u16>, pid: Option<u16>) -> DeviceConnection {
347        DeviceConnection {
348            bus: Bus::Usb,
349            device_class_guid: None,
350            vid,
351            pid,
352            device_serial: None,
353            serial_is_os_generated: false,
354            friendly_name: None,
355            device_instance_id: String::new(),
356            first_install: None,
357            last_install: None,
358            last_arrival: None,
359            last_removal: None,
360            parent_id_prefix: None,
361            volume_guid: None,
362            drive_letter: None,
363            volume_serial: None,
364            disk_signature: None,
365            dma_capable: false,
366            mitre: Vec::new(),
367            source: Provenance {
368                file: String::new(),
369                line: 0,
370                key_path: None,
371            },
372        }
373    }
374
375    const IDS: &str = "0781  SanDisk Corp.\n\t5583  Ultra Fit\n";
376
377    #[test]
378    fn vendor_name_resolves_a_known_vid_and_stays_none_otherwise() {
379        let db = crate::usb_ids::UsbIdDb::parse(IDS);
380        assert_eq!(
381            connection(Some(0x0781), None).vendor_name(&db),
382            Some("SanDisk Corp.")
383        );
384        // Unknown vid: the lookup misses rather than inventing a name.
385        assert_eq!(connection(Some(0xFFFF), None).vendor_name(&db), None);
386        // Absent vid: nothing to look up.
387        assert_eq!(connection(None, None).vendor_name(&db), None);
388    }
389
390    #[test]
391    fn product_name_needs_both_ids() {
392        let db = crate::usb_ids::UsbIdDb::parse(IDS);
393        assert_eq!(
394            connection(Some(0x0781), Some(0x5583)).product_name(&db),
395            Some("Ultra Fit")
396        );
397        // Each half alone falls to the `_ => None` arm: a pid without its vid is
398        // not a product key, and a vid alone does not name a product.
399        assert_eq!(connection(Some(0x0781), None).product_name(&db), None);
400        assert_eq!(connection(None, Some(0x5583)).product_name(&db), None);
401        assert_eq!(connection(None, None).product_name(&db), None);
402    }
403}