1#![forbid(unsafe_code)]
10
11use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
12use peripheral_core::{Bus, DeviceConnection};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum DeviceAnomaly {
17 DmaCapableDevice {
21 instance_id: String,
23 bus: Bus,
25 },
26 MassStorageConnected {
29 instance_id: String,
31 },
32 HidDevice {
35 instance_id: String,
37 },
38 OsGeneratedSerial {
41 instance_id: String,
43 },
44}
45
46impl DeviceAnomaly {
47 #[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#[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#[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
147fn 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#[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 assert!(
234 codes(&audit(&[conn("BTHENUM\\X", Bus::Bluetooth, false, false)]))
235 .contains(&"PERIPHERAL-HID-DEVICE")
236 );
237 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 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 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}