Skip to main content

peripheral_forensic/
lib.rs

1//! `peripheral-forensic` — graded anomaly auditor over external-device
2//! connections.
3//!
4//! Consumes [`peripheral_core::DeviceConnection`] records and emits
5//! [`forensicnomicon::report::Finding`]s. Every anomaly is an **observation**
6//! ("consistent with …"); the examiner draws the conclusions. MITRE techniques
7//! are narrated as consistency, never as a verdict.
8
9#![forbid(unsafe_code)]
10
11use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
12use peripheral_core::{Bus, DeviceConnection};
13
14/// A graded external-device anomaly.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum DeviceAnomaly {
17    /// A bus-mastering DMA-capable device (FireWire / Thunderbolt / PCIe /
18    /// ExpressCard) was connected — a direct-memory-access attack surface.
19    /// MITRE T1200.
20    DmaCapableDevice {
21        /// The device instance id.
22        instance_id: String,
23        /// The DMA-capable bus.
24        bus: Bus,
25    },
26    /// Removable mass storage was connected — an exfiltration / autorun-payload
27    /// surface. MITRE T1052.001 / T1091.
28    MassStorageConnected {
29        /// The device instance id.
30        instance_id: String,
31    },
32    /// A Human Interface Device was connected — possible keystroke-injection
33    /// (BadUSB). MITRE T1200.
34    HidDevice {
35        /// The device instance id.
36        instance_id: String,
37    },
38    /// The device's serial was synthesized by Windows (no real iSerial), so
39    /// attribution back to a specific physical device is weaker.
40    OsGeneratedSerial {
41        /// The device instance id.
42        instance_id: String,
43    },
44}
45
46impl DeviceAnomaly {
47    /// The stable, published anomaly code (scheme-prefixed SCREAMING-KEBAB).
48    #[must_use]
49    pub fn code(&self) -> &'static str {
50        match self {
51            Self::DmaCapableDevice { .. } => "PERIPHERAL-DMA-CAPABLE-DEVICE",
52            Self::MassStorageConnected { .. } => "PERIPHERAL-MASS-STORAGE-CONNECTED",
53            Self::HidDevice { .. } => "PERIPHERAL-HID-DEVICE",
54            Self::OsGeneratedSerial { .. } => "PERIPHERAL-OS-GENERATED-SERIAL",
55        }
56    }
57}
58
59impl Observation for DeviceAnomaly {
60    fn severity(&self) -> Option<Severity> {
61        Some(match self {
62            Self::DmaCapableDevice { .. } => Severity::High,
63            Self::MassStorageConnected { .. } | Self::HidDevice { .. } => Severity::Medium,
64            Self::OsGeneratedSerial { .. } => Severity::Low,
65        })
66    }
67
68    fn code(&self) -> &'static str {
69        DeviceAnomaly::code(self)
70    }
71
72    fn category(&self) -> Category {
73        match self {
74            Self::DmaCapableDevice { .. }
75            | Self::MassStorageConnected { .. }
76            | Self::HidDevice { .. } => Category::Threat,
77            Self::OsGeneratedSerial { .. } => Category::Integrity,
78        }
79    }
80
81    fn mitre(&self) -> &'static [&'static str] {
82        match self {
83            Self::DmaCapableDevice { .. } | Self::HidDevice { .. } => &["T1200"],
84            Self::MassStorageConnected { .. } => &["T1052.001", "T1091"],
85            Self::OsGeneratedSerial { .. } => &[],
86        }
87    }
88
89    fn note(&self) -> String {
90        match self {
91            Self::DmaCapableDevice { instance_id, bus } => format!(
92                "a {bus:?} device ({instance_id:?}) connected; the bus is bus-mastering \
93                 DMA-capable, consistent with a direct-memory-access attack surface \
94                 (MITRE T1200)"
95            ),
96            Self::MassStorageConnected { instance_id } => format!(
97                "removable mass storage ({instance_id:?}) connected; consistent with data \
98                 staging/exfiltration or autorun payload delivery (MITRE T1052.001 / T1091)"
99            ),
100            Self::HidDevice { instance_id } => format!(
101                "a human-interface device ({instance_id:?}) connected; consistent with \
102                 keystroke-injection hardware such as BadUSB (MITRE T1200)"
103            ),
104            Self::OsGeneratedSerial { instance_id } => format!(
105                "the device ({instance_id:?}) exposed no real iSerial — Windows synthesized \
106                 the instance-id serial; consistent with weaker device attribution"
107            ),
108        }
109    }
110}
111
112/// Audit a slice of [`DeviceConnection`]s into a typed [`DeviceAnomaly`] stream.
113#[must_use]
114pub fn audit(devices: &[DeviceConnection]) -> Vec<DeviceAnomaly> {
115    let mut out = Vec::new();
116    for d in devices {
117        let id = || d.device_instance_id.clone();
118        if d.dma_capable {
119            out.push(DeviceAnomaly::DmaCapableDevice {
120                instance_id: id(),
121                bus: d.bus,
122            });
123        }
124        if d.bus.is_mass_storage() {
125            out.push(DeviceAnomaly::MassStorageConnected { instance_id: id() });
126        }
127        if is_hid(d) {
128            out.push(DeviceAnomaly::HidDevice { instance_id: id() });
129        }
130        if d.serial_is_os_generated {
131            out.push(DeviceAnomaly::OsGeneratedSerial { instance_id: id() });
132        }
133    }
134    out
135}
136
137/// Convenience: audit and convert directly to graded [`Finding`]s.
138#[must_use]
139pub fn audit_findings(devices: &[DeviceConnection], scope: impl Into<String>) -> Vec<Finding> {
140    let src = source(scope);
141    audit(devices)
142        .iter()
143        .map(|a| a.to_finding(src.clone()))
144        .collect()
145}
146
147/// Whether a connection is a Human Interface Device — a Bluetooth transport, or
148/// a USB device whose instance id names the HID class.
149fn is_hid(d: &DeviceConnection) -> bool {
150    if d.bus == Bus::Bluetooth {
151        return true;
152    }
153    let id = d.device_instance_id.to_ascii_uppercase();
154    id.starts_with("HID\\") || id.contains("\\HID") || id.contains("&HID")
155}
156
157/// The [`Source`] stamp for findings this analyzer emits.
158#[must_use]
159pub fn source(scope: impl Into<String>) -> Source {
160    Source {
161        analyzer: "peripheral-forensic".to_string(),
162        scope: scope.into(),
163        version: Some(env!("CARGO_PKG_VERSION").to_string()),
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use peripheral_core::{MitreRef, Provenance};
171
172    fn conn(instance_id: &str, bus: Bus, dma: bool, os_serial: bool) -> DeviceConnection {
173        DeviceConnection {
174            bus,
175            device_class_guid: None,
176            vid: None,
177            pid: None,
178            device_serial: None,
179            serial_is_os_generated: os_serial,
180            friendly_name: None,
181            device_instance_id: instance_id.to_string(),
182            first_install: None,
183            last_install: None,
184            last_arrival: None,
185            last_removal: None,
186            parent_id_prefix: None,
187            volume_guid: None,
188            drive_letter: None,
189            volume_serial: None,
190            disk_signature: None,
191            dma_capable: dma,
192            mitre: vec![MitreRef("T1200")],
193            source: Provenance {
194                file: "f".into(),
195                line: 1,
196            },
197        }
198    }
199
200    fn codes(a: &[DeviceAnomaly]) -> Vec<&str> {
201        a.iter().map(DeviceAnomaly::code).collect()
202    }
203
204    #[test]
205    fn dma_device_is_flagged_high_threat() {
206        let a = audit(&[conn("1394\\X\\0", Bus::FireWire, true, false)]);
207        assert!(codes(&a).contains(&"PERIPHERAL-DMA-CAPABLE-DEVICE"));
208        let dma = a
209            .iter()
210            .find(|x| x.code() == "PERIPHERAL-DMA-CAPABLE-DEVICE")
211            .unwrap();
212        assert_eq!(dma.severity(), Some(Severity::High));
213        assert_eq!(dma.category(), Category::Threat);
214        assert!(dma.mitre().contains(&"T1200"));
215    }
216
217    #[test]
218    fn mass_storage_is_flagged_medium_threat() {
219        let a = audit(&[conn("USBSTOR\\Disk\\X", Bus::Usb, false, false)]);
220        assert!(codes(&a).contains(&"PERIPHERAL-MASS-STORAGE-CONNECTED"));
221        let ms = a
222            .iter()
223            .find(|x| x.code() == "PERIPHERAL-MASS-STORAGE-CONNECTED")
224            .unwrap();
225        assert_eq!(ms.severity(), Some(Severity::Medium));
226        assert!(ms.mitre().contains(&"T1052.001"));
227        assert!(ms.mitre().contains(&"T1091"));
228    }
229
230    #[test]
231    fn hid_device_is_flagged() {
232        // Bluetooth transport.
233        assert!(
234            codes(&audit(&[conn("BTHENUM\\X", Bus::Bluetooth, false, false)]))
235                .contains(&"PERIPHERAL-HID-DEVICE")
236        );
237        // USB HID class in the instance id.
238        assert!(codes(&audit(&[conn(
239            "HID\\VID_046D&PID_C52B\\X",
240            Bus::Usb,
241            false,
242            false
243        )]))
244        .contains(&"PERIPHERAL-HID-DEVICE"));
245    }
246
247    #[test]
248    fn os_generated_serial_is_flagged_low_integrity() {
249        let a = audit(&[conn("USBSTOR\\Disk\\7&abc&0", Bus::Usb, false, true)]);
250        assert!(codes(&a).contains(&"PERIPHERAL-OS-GENERATED-SERIAL"));
251        let os = a
252            .iter()
253            .find(|x| x.code() == "PERIPHERAL-OS-GENERATED-SERIAL")
254            .unwrap();
255        assert_eq!(os.severity(), Some(Severity::Low));
256        assert_eq!(os.category(), Category::Integrity);
257        assert!(os.mitre().is_empty());
258    }
259
260    #[test]
261    fn benign_non_storage_non_dma_device_fires_nothing() {
262        // A keyboard-less PCI... actually PCI is DMA; use an MTP phone (no flags).
263        let a = audit(&[conn("WpdBusEnumRoot\\X", Bus::Mtp, false, false)]);
264        assert!(a.is_empty(), "got {:?}", codes(&a));
265    }
266
267    #[test]
268    fn findings_are_hedged_observations_never_verdicts() {
269        // Exercise every anomaly kind's note arm: DMA bus + mass-storage +
270        // OS-generated serial on one USB device, plus a Bluetooth HID device.
271        let f = audit_findings(
272            &[
273                conn("1394\\X\\0", Bus::FireWire, true, false),
274                conn("USBSTOR\\Disk\\7&abc&0", Bus::Usb, false, true),
275                conn("BTHENUM\\X", Bus::Bluetooth, false, false),
276            ],
277            "host",
278        );
279        assert_eq!(
280            f.len(),
281            4,
282            "DMA + (mass-storage + os-serial) + hid = 4 findings"
283        );
284        for finding in &f {
285            let note = finding.note.to_ascii_lowercase();
286            assert!(note.contains("consistent with"), "must hedge: {note}");
287            for forbidden in ["proves", "confirms", "definitely"] {
288                assert!(
289                    !note.contains(forbidden),
290                    "must not assert a verdict: {note}"
291                );
292            }
293        }
294    }
295
296    #[test]
297    fn source_stamps_analyzer_and_version() {
298        let s = source("partition 1");
299        assert_eq!(s.analyzer, "peripheral-forensic");
300        assert_eq!(s.scope, "partition 1");
301        assert!(s.version.is_some());
302    }
303}