peripheral-core 0.8.2

External-device connection forensic reader: parses Windows setupapi.dev.log and SYSTEM-hive registry device keys into typed DeviceConnection records with bus classification and authoritative-vs-inferred timestamp tagging
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! `peripheral-core` — external-device (peripheral) connection forensic reader.
//!
//! Parses Windows `setupapi.dev.log` device-installation logs into a uniform
//! [`DeviceConnection`] stream: bus-classified, with each timestamp tagged
//! authoritative-vs-inferred and the USB iSerial kept distinct from any volume
//! serial. The input is attacker-controllable evidence — parsing is lenient
//! (lossy UTF-8), bounds-checked, and never panics. No `unsafe`.
//!
//! Findings (DMA-capable device, mass-storage, HID/BadUSB, OS-generated serial)
//! live in the sibling `peripheral-forensic` crate; this crate only decodes.
//!
//! ## v0.2 enrichment (not in this release)
//!
//! The richest source — the Windows registry `SYSTEM\CurrentControlSet\Enum\`
//! keys (USBSTOR/USB), `MountedDevices`, and the device-property `0066`/`0067`
//! Last-Arrival/Last-Removal `FILETIME`s — plus EVTX device events require the
//! (unpublished) `winreg-core` and `winevt-forensic` crates. They are deferred
//! to v0.2; v0.1 is scoped to the self-contained `setupapi.dev.log` source.

#![forbid(unsafe_code)]

pub mod emdmgmt;
pub mod linux_syslog;
pub mod mounted_volumes;
pub mod mountpoints2;
pub mod registry;
pub mod setupapi;
pub mod shellbag;
pub mod usb_ids;
pub mod volume_info;

/// The physical/logical bus a peripheral attached through.
///
/// The variant drives the DMA-capability and storage-class threat lenses
/// downstream (see [`DeviceConnection::dma_capable`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Bus {
    /// USB (host-controller mediated; not directly DMA-capable as mass storage).
    Usb,
    /// Media Transfer Protocol (phones/cameras) — surfaced via `WpdBusEnumRoot`.
    Mtp,
    /// IEEE 1394 FireWire — bus-mastering DMA.
    FireWire,
    /// Thunderbolt — PCIe tunnelled, bus-mastering DMA.
    Thunderbolt,
    /// PCI Express — bus-mastering DMA.
    Pcie,
    /// External SATA — SATA/storage transport, explicitly NOT DMA.
    Esata,
    /// SD/MMC card.
    SdMmc,
    /// Bluetooth (typically HID/wireless).
    Bluetooth,
    /// ExpressCard — PCIe-backed, bus-mastering DMA.
    ExpressCard,
    /// SCSI / SAS storage transport.
    ScsiSas,
    /// NVMe storage.
    Nvme,
    /// Bus could not be determined from the enumerator.
    Unknown,
}

impl Bus {
    /// Classify a bus from a setupapi/instance-id **enumerator** prefix — the
    /// leading token of a device instance id (`USBSTOR`, `USB`, `1394`, `PCI`,
    /// `SCSI`, `SD`, `WpdBusEnumRoot`, …), matched case-insensitively.
    ///
    /// Returns [`Bus::Unknown`] for an unrecognized or empty enumerator; the
    /// caller never gets a panic.
    #[must_use]
    pub fn from_enumerator(enumerator: &str) -> Self {
        let e = enumerator.trim().to_ascii_uppercase();
        match e.as_str() {
            "USBSTOR" | "USB" => Self::Usb,
            "1394" => Self::FireWire,
            "THUNDERBOLT" => Self::Thunderbolt,
            "PCI" | "PCIE" => Self::Pcie,
            "SCSI" | "SAS" => Self::ScsiSas,
            "NVME" => Self::Nvme,
            "SD" | "MMC" | "SDBUS" => Self::SdMmc,
            "ESATA" => Self::Esata,
            "BTHENUM" | "BTHLE" | "BLUETOOTH" => Self::Bluetooth,
            "EXPRESSCARD" => Self::ExpressCard,
            "WPDBUSENUMROOT" | "MTP" => Self::Mtp,
            _ => Self::Unknown,
        }
    }

    /// Whether this bus can perform **bus-mastering DMA**, the property that
    /// makes a device a direct-memory-access attack surface (MITRE T1200).
    ///
    /// DMA-capable: FireWire, Thunderbolt, PCIe, ExpressCard. Storage-class
    /// transports (USB mass storage, eSATA, SD/MMC, SCSI/SAS, NVMe) and
    /// HID/wireless transports (USB-HID, Bluetooth) are NOT DMA in this model.
    ///
    /// Caveat: SD-Express tunnels PCIe and *can* be DMA-capable; this v0.1
    /// classifier treats bare `SD` as the legacy non-DMA SD/MMC bus, the common
    /// case. Distinguishing SD-Express needs the device-capability bits that the
    /// registry/EVTX v0.2 source carries.
    #[must_use]
    pub fn is_dma_capable(self) -> bool {
        matches!(
            self,
            Self::FireWire | Self::Thunderbolt | Self::Pcie | Self::ExpressCard
        )
    }

    /// Whether this bus is a removable mass-storage transport (the
    /// data-exfiltration / autorun lens, MITRE T1052.001 / T1091).
    #[must_use]
    pub fn is_mass_storage(self) -> bool {
        matches!(
            self,
            Self::Usb | Self::Esata | Self::SdMmc | Self::ScsiSas | Self::Nvme
        )
    }
}

/// How much trust a timestamp carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Confidence {
    /// Directly recorded by the source as the stated event
    /// (e.g. the setupapi section-header install time → first-seen).
    Authoritative,
    /// Derived/undocumented — the value's meaning is inferred, not stated by the
    /// source (e.g. the registry `0066`/`0067` Last-Arrival/Last-Removal
    /// device-property `FILETIME`s, which are undocumented).
    Inferred,
}

/// A timestamp tagged with its evidentiary confidence.
///
/// Pairing the value with its [`Confidence`] in the type makes the
/// authoritative-vs-inferred distinction impossible to drop on the floor: a
/// consumer cannot read `value` without also seeing how trustworthy it is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Stamp {
    /// Unix epoch seconds.
    pub value: i64,
    /// How the value should be trusted.
    pub confidence: Confidence,
}

impl Stamp {
    /// An authoritative (source-stated) timestamp.
    #[must_use]
    pub fn authoritative(value: i64) -> Self {
        Self {
            value,
            confidence: Confidence::Authoritative,
        }
    }

    /// An inferred (derived/undocumented) timestamp.
    #[must_use]
    pub fn inferred(value: i64) -> Self {
        Self {
            value,
            confidence: Confidence::Inferred,
        }
    }
}

/// A MITRE ATT&CK technique a connection is *consistent with* — never a verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MitreRef(pub &'static str);

/// One external-device connection, normalized across sources.
///
/// The forensic cautions are baked into the type, not just the docs:
/// - [`device_serial`](Self::device_serial) is the **USB iSerial** and is a
///   distinct field from [`volume_serial`](Self::volume_serial) (a filesystem
///   volume serial), so the two can never be conflated.
/// - [`serial_is_os_generated`](Self::serial_is_os_generated) records that the
///   device had no real iSerial (Windows synthesized one), weakening attribution.
/// - Each timestamp is a [`Stamp`] carrying its authoritative-vs-inferred
///   [`Confidence`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceConnection {
    // ── Identity ────────────────────────────────────────────────────────────
    /// The classified bus.
    pub bus: Bus,
    /// Device setup-class GUID, when known.
    pub device_class_guid: Option<String>,
    /// USB vendor id (`VID_xxxx`).
    pub vid: Option<u16>,
    /// USB product id (`PID_xxxx`).
    pub pid: Option<u16>,
    /// The **USB iSerial** — the device-unique serial reported by the device.
    /// DISTINCT from any [`volume_serial`](Self::volume_serial).
    pub device_serial: Option<String>,
    /// `true` when the instance-id serial was synthesized by Windows (the
    /// serial's 2nd character is `&`) — the device exposed no real iSerial, so
    /// attribution is weaker.
    pub serial_is_os_generated: bool,
    /// Human-readable friendly name, when present.
    pub friendly_name: Option<String>,
    /// The full device instance id (e.g.
    /// `USB\VID_0781&PID_5583\1234567890AB`) — the primary key.
    pub device_instance_id: String,

    // ── Timestamps (each tagged authoritative-vs-inferred) ───────────────────
    /// First-seen / first-install — authoritative when from the setupapi
    /// section header.
    pub first_install: Option<Stamp>,
    /// Last install/driver event.
    pub last_install: Option<Stamp>,
    /// Last arrival (connect). INFERRED — derived from the undocumented registry
    /// `0066` device property (v0.2).
    pub last_arrival: Option<Stamp>,
    /// Last removal (disconnect). INFERRED — derived from the undocumented
    /// registry `0067` device property (v0.2).
    pub last_removal: Option<Stamp>,

    // ── Correlation join keys (volume_serial kept DISTINCT from device_serial) ─
    /// `ParentIdPrefix` — joins the storage device to its volume.
    pub parent_id_prefix: Option<String>,
    /// Volume GUID (`\\?\Volume{...}`).
    pub volume_guid: Option<String>,
    /// Mounted drive letter.
    pub drive_letter: Option<char>,
    /// Filesystem **volume** serial (NTFS/FAT) — DISTINCT from the device's
    /// USB [`device_serial`](Self::device_serial).
    pub volume_serial: Option<u32>,
    /// MBR disk signature.
    pub disk_signature: Option<u32>,

    // ── Threat lens ──────────────────────────────────────────────────────────
    /// Whether the bus is bus-mastering DMA-capable (see [`Bus::is_dma_capable`]).
    pub dma_capable: bool,
    /// MITRE ATT&CK techniques this connection is *consistent with*.
    pub mitre: Vec<MitreRef>,

    // ── Provenance ───────────────────────────────────────────────────────────
    /// Where this record came from (source file + 1-based line).
    pub source: Provenance,
}

impl DeviceConnection {
    /// Resolve the vendor **name** for this connection's [`vid`](Self::vid) via a
    /// [`UsbIdDb`](crate::usb_ids::UsbIdDb).
    ///
    /// **Non-authoritative enrichment.** The raw numeric `vid` is the evidence;
    /// this is a lookup convenience and is `None` when the vid is absent or unknown.
    #[must_use]
    pub fn vendor_name<'a>(&self, db: &'a crate::usb_ids::UsbIdDb) -> Option<&'a str> {
        self.vid.and_then(|v| db.vendor_name(v))
    }

    /// Resolve the product **name** for this connection's `vid`/`pid` via a
    /// [`UsbIdDb`](crate::usb_ids::UsbIdDb). Non-authoritative (see
    /// [`vendor_name`](Self::vendor_name)); `None` unless both ids are present and known.
    #[must_use]
    pub fn product_name<'a>(&self, db: &'a crate::usb_ids::UsbIdDb) -> Option<&'a str> {
        match (self.vid, self.pid) {
            (Some(v), Some(p)) => db.product_name(v, p),
            _ => None,
        }
    }
}

/// Where a [`DeviceConnection`] was decoded from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
    /// The source file (e.g. `setupapi.dev.log`, or the hive name `SYSTEM`).
    pub file: String,
    /// 1-based line number of the section header the record came from, for
    /// line-oriented sources (`setupapi.dev.log`). `0` when the source is not
    /// line-oriented (e.g. a registry hive — see [`key_path`](Self::key_path)).
    pub line: usize,
    /// The full registry key path the record was decoded from, for hive sources
    /// (e.g. `ControlSet001\Enum\SCSI\Disk&Ven_…\5&22be343f&0&000000`). `None` for
    /// line-oriented sources.
    pub key_path: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn usb_enumerators_classify_as_usb() {
        assert_eq!(Bus::from_enumerator("USBSTOR"), Bus::Usb);
        assert_eq!(Bus::from_enumerator("USB"), Bus::Usb);
        assert_eq!(Bus::from_enumerator("usbstor"), Bus::Usb); // case-insensitive
    }

    #[test]
    fn bus_specific_enumerators_classify() {
        assert_eq!(Bus::from_enumerator("1394"), Bus::FireWire);
        assert_eq!(Bus::from_enumerator("SCSI"), Bus::ScsiSas);
        assert_eq!(Bus::from_enumerator("PCI"), Bus::Pcie);
        assert_eq!(Bus::from_enumerator("SD"), Bus::SdMmc);
        assert_eq!(Bus::from_enumerator("WpdBusEnumRoot"), Bus::Mtp);
        assert_eq!(Bus::from_enumerator("THUNDERBOLT"), Bus::Thunderbolt);
        assert_eq!(Bus::from_enumerator("ESATA"), Bus::Esata);
        assert_eq!(Bus::from_enumerator("EXPRESSCARD"), Bus::ExpressCard);
        assert_eq!(Bus::from_enumerator("BTHENUM"), Bus::Bluetooth);
        assert_eq!(Bus::from_enumerator("NVME"), Bus::Nvme);
    }

    #[test]
    fn unknown_enumerator_is_unknown_never_panics() {
        assert_eq!(Bus::from_enumerator("HID"), Bus::Unknown);
        assert_eq!(Bus::from_enumerator(""), Bus::Unknown);
        assert_eq!(Bus::from_enumerator("   "), Bus::Unknown);
    }

    #[test]
    fn dma_capable_is_exactly_firewire_thunderbolt_pcie_expresscard() {
        for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Pcie, Bus::ExpressCard] {
            assert!(b.is_dma_capable(), "{b:?} must be DMA-capable");
        }
        // Storage-only transports are explicitly NOT DMA (eSATA is SATA/storage).
        for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
            assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
        }
        // HID/wireless transports are not DMA either.
        for b in [Bus::Bluetooth, Bus::Mtp, Bus::Unknown] {
            assert!(!b.is_dma_capable(), "{b:?} must NOT be DMA-capable");
        }
    }

    #[test]
    fn mass_storage_classes() {
        for b in [Bus::Usb, Bus::Esata, Bus::SdMmc, Bus::ScsiSas, Bus::Nvme] {
            assert!(b.is_mass_storage(), "{b:?} should be mass storage");
        }
        for b in [Bus::FireWire, Bus::Thunderbolt, Bus::Bluetooth, Bus::Mtp] {
            assert!(!b.is_mass_storage(), "{b:?} should not be mass storage");
        }
    }

    #[test]
    fn stamp_carries_confidence() {
        assert_eq!(
            Stamp::authoritative(10).confidence,
            Confidence::Authoritative
        );
        assert_eq!(Stamp::inferred(10).confidence, Confidence::Inferred);
    }

    /// A connection carrying only the ids the name lookups read. Every other
    /// field is empty on purpose: these two methods must not depend on them.
    fn connection(vid: Option<u16>, pid: Option<u16>) -> DeviceConnection {
        DeviceConnection {
            bus: Bus::Usb,
            device_class_guid: None,
            vid,
            pid,
            device_serial: None,
            serial_is_os_generated: false,
            friendly_name: None,
            device_instance_id: String::new(),
            first_install: None,
            last_install: None,
            last_arrival: None,
            last_removal: None,
            parent_id_prefix: None,
            volume_guid: None,
            drive_letter: None,
            volume_serial: None,
            disk_signature: None,
            dma_capable: false,
            mitre: Vec::new(),
            source: Provenance {
                file: String::new(),
                line: 0,
                key_path: None,
            },
        }
    }

    const IDS: &str = "0781  SanDisk Corp.\n\t5583  Ultra Fit\n";

    #[test]
    fn vendor_name_resolves_a_known_vid_and_stays_none_otherwise() {
        let db = crate::usb_ids::UsbIdDb::parse(IDS);
        assert_eq!(
            connection(Some(0x0781), None).vendor_name(&db),
            Some("SanDisk Corp.")
        );
        // Unknown vid: the lookup misses rather than inventing a name.
        assert_eq!(connection(Some(0xFFFF), None).vendor_name(&db), None);
        // Absent vid: nothing to look up.
        assert_eq!(connection(None, None).vendor_name(&db), None);
    }

    #[test]
    fn product_name_needs_both_ids() {
        let db = crate::usb_ids::UsbIdDb::parse(IDS);
        assert_eq!(
            connection(Some(0x0781), Some(0x5583)).product_name(&db),
            Some("Ultra Fit")
        );
        // Each half alone falls to the `_ => None` arm: a pid without its vid is
        // not a product key, and a vid alone does not name a product.
        assert_eq!(connection(Some(0x0781), None).product_name(&db), None);
        assert_eq!(connection(None, Some(0x5583)).product_name(&db), None);
        assert_eq!(connection(None, None).product_name(&db), None);
    }
}