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