1use forensicnomicon::report::{Category, Finding, Severity, Source};
30use livedisk::PhysicalDisk;
31
32const ANALYZER: &str = "livedisk-forensic";
34
35fn source(disk: &PhysicalDisk) -> Source {
36 Source {
37 analyzer: ANALYZER.to_string(),
38 scope: disk.name.clone(),
39 version: Some(env!("CARGO_PKG_VERSION").to_string()),
40 }
41}
42
43#[must_use]
47pub fn analyse(disk: &PhysicalDisk) -> Vec<Finding> {
48 let mut findings = Vec::new();
49
50 let mounted = disk
52 .partitions
53 .iter()
54 .filter(|p| p.mount_point.is_some())
55 .count();
56 if mounted > 0 {
57 let mut builder = Finding::observation(Severity::High, Category::Integrity, "LIVE-MOUNTED")
58 .source(source(disk))
59 .note(
60 "device has mounted volume(s) during acquisition; live writes may alter the \
61 image — consistent with imaging a running system",
62 );
63 for p in &disk.partitions {
64 if let Some(mount) = &p.mount_point {
65 builder = builder.evidence(p.name.clone(), mount.clone());
66 }
67 }
68 findings.push(builder.build());
69 }
70
71 if disk.removable {
78 findings.push(
79 Finding::observation(Severity::Info, Category::Provenance, "LIVE-REMOVABLE")
80 .source(source(disk))
81 .note("removable media")
82 .build(),
83 );
84 }
85
86 if disk.logical_sector_size > 0 && disk.physical_sector_size != disk.logical_sector_size {
88 findings.push(
89 Finding::observation(Severity::Info, Category::Structure, "LIVE-SECTOR-4KN")
90 .source(source(disk))
91 .note(
92 "logical and physical sector sizes differ (512e/4Kn); align imaging to the \
93 physical sector size",
94 )
95 .evidence("logical_sector_size", disk.logical_sector_size.to_string())
96 .evidence(
97 "physical_sector_size",
98 disk.physical_sector_size.to_string(),
99 )
100 .build(),
101 );
102 }
103
104 if disk.synthesized {
106 findings.push(
107 Finding::observation(Severity::Info, Category::Provenance, "LIVE-SYNTHESIZED")
108 .source(source(disk))
109 .note(
110 "synthesized device — a container overlay (e.g. APFS container, \
111 device-mapper/LVM) over one or more physical stores, not itself a backing \
112 physical disk",
113 )
114 .build(),
115 );
116 }
117
118 findings
119}
120
121#[must_use]
129pub fn analyse_target(disk: &PhysicalDisk) -> Vec<Finding> {
130 let mut findings = analyse(disk);
131 if !disk.read_only {
132 findings.push(
133 Finding::observation(Severity::High, Category::Integrity, "LIVE-WRITABLE")
134 .source(source(disk))
135 .note(
136 "acquisition target is writable — no hardware write-blocker is engaged; \
137 imaging can alter the evidence",
138 )
139 .build(),
140 );
141 }
142 findings
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use livedisk::Partition;
149
150 fn clean_disk() -> PhysicalDisk {
153 PhysicalDisk {
154 device_path: "/dev/disk0".into(),
155 name: "disk0".into(),
156 size_bytes: 1_000_000_000_000,
157 logical_sector_size: 512,
158 physical_sector_size: 512,
159 model: Some("WRITE BLOCKED".into()),
160 serial: None,
161 removable: false,
162 read_only: true,
163 synthesized: false,
164 partitions: vec![],
165 }
166 }
167
168 fn codes(findings: &[Finding]) -> Vec<&str> {
169 findings.iter().map(|f| f.code.as_ref()).collect()
170 }
171
172 #[test]
173 fn clean_write_protected_disk_has_no_findings() {
174 assert!(analyse(&clean_disk()).is_empty());
175 }
176
177 #[test]
178 fn overview_analyse_omits_live_writable() {
179 let mut d = clean_disk();
182 d.read_only = false;
183 assert!(!codes(&analyse(&d)).contains(&"LIVE-WRITABLE"));
184 }
185
186 #[test]
187 fn target_analyse_flags_writable_high() {
188 let mut d = clean_disk();
191 d.read_only = false;
192 let findings = analyse_target(&d);
193 let f = findings.iter().find(|f| f.code == "LIVE-WRITABLE").unwrap();
194 assert_eq!(f.severity, Some(Severity::High));
195 assert_eq!(f.source.analyzer, "livedisk-forensic");
196 assert_eq!(f.source.scope, "disk0");
197 }
198
199 #[test]
200 fn target_analyse_write_blocked_has_no_writable() {
201 assert!(!codes(&analyse_target(&clean_disk())).contains(&"LIVE-WRITABLE"));
203 }
204
205 #[test]
206 fn mounted_disk_flags_live_mounted_high_with_evidence() {
207 let mut d = clean_disk();
208 d.partitions = vec![Partition {
209 device_path: "/dev/disk0s1".into(),
210 name: "disk0s1".into(),
211 start_offset: 0,
212 size_bytes: 1,
213 partition_type: None,
214 mount_point: Some("/Volumes/Data".into()),
215 filesystem: None,
216 label: None,
217 }];
218 let findings = analyse(&d);
219 let f = findings.iter().find(|f| f.code == "LIVE-MOUNTED").unwrap();
220 assert_eq!(f.severity, Some(Severity::High));
221 assert!(f.evidence.iter().any(|e| e.value == "/Volumes/Data"));
222 }
223
224 #[test]
225 fn removable_disk_flags_live_removable_info() {
226 let mut d = clean_disk();
227 d.removable = true;
228 assert!(codes(&analyse(&d)).contains(&"LIVE-REMOVABLE"));
229 }
230
231 #[test]
232 fn sector_mismatch_flags_4kn_with_both_sizes() {
233 let mut d = clean_disk();
234 d.logical_sector_size = 512;
235 d.physical_sector_size = 4096;
236 let findings = analyse(&d);
237 let f = findings
238 .iter()
239 .find(|f| f.code == "LIVE-SECTOR-4KN")
240 .unwrap();
241 assert!(f.evidence.iter().any(|e| e.value == "4096"));
242 assert!(f.evidence.iter().any(|e| e.value == "512"));
243 }
244
245 #[test]
246 fn synthesized_disk_flags_live_synthesized() {
247 let mut d = clean_disk();
248 d.synthesized = true;
249 assert!(codes(&analyse(&d)).contains(&"LIVE-SYNTHESIZED"));
250 }
251}