1#![forbid(unsafe_code)]
26#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
27
28use std::io::{Read, Seek};
29
30use forensicnomicon::report::{
31 Category, Evidence, Finding, Observation, Severity, Source, SubjectRef, Timestamp,
32};
33use vsc::VssVolume;
34
35#[cfg(test)]
36mod tests;
37
38pub const ANALYZER: &str = "vsc-forensic";
40
41const FILETIME_EPOCH_DIFF: u64 = 116_444_736_000_000_000;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum AnomalyKind {
48 NoShadowCopies,
52 StorePresent {
54 store_id: String,
56 sequence: u64,
58 volume_size: u64,
60 creation_time: u64,
62 },
63 SequenceGap {
66 previous: u64,
68 next: u64,
70 },
71 StoreNonPersistent {
74 store_id: String,
76 attribute_flags: u32,
78 },
79}
80
81impl AnomalyKind {
82 #[must_use]
84 pub fn severity(&self) -> Severity {
85 match self {
86 AnomalyKind::StorePresent { .. } => Severity::Info,
87 AnomalyKind::NoShadowCopies | AnomalyKind::StoreNonPersistent { .. } => Severity::Low,
88 AnomalyKind::SequenceGap { .. } => Severity::Medium,
89 }
90 }
91
92 #[must_use]
94 pub fn code(&self) -> &'static str {
95 match self {
96 AnomalyKind::NoShadowCopies => "VSC-NO-SHADOW-COPIES",
97 AnomalyKind::StorePresent { .. } => "VSC-STORE-PRESENT",
98 AnomalyKind::SequenceGap { .. } => "VSC-SEQUENCE-GAP",
99 AnomalyKind::StoreNonPersistent { .. } => "VSC-STORE-NON-PERSISTENT",
100 }
101 }
102
103 #[must_use]
105 pub fn category(&self) -> Category {
106 match self {
107 AnomalyKind::NoShadowCopies | AnomalyKind::StorePresent { .. } => Category::History,
108 AnomalyKind::SequenceGap { .. } => Category::Residue,
109 AnomalyKind::StoreNonPersistent { .. } => Category::Provenance,
110 }
111 }
112
113 #[must_use]
115 pub fn note(&self) -> String {
116 match self {
117 AnomalyKind::NoShadowCopies => {
118 "the volume carries a VSS volume header but the catalog \
119 enumerated zero shadow-copy stores; consistent with shadow-copy deletion (MITRE \
120 T1490) or a volume that never had snapshots — not a determination of deletion"
121 .to_string()
122 }
123 AnomalyKind::StorePresent {
124 store_id,
125 sequence,
126 volume_size,
127 ..
128 } => format!(
129 "shadow copy {store_id} is present (catalog sequence {sequence}, shadow volume \
130 size {volume_size} bytes)"
131 ),
132 AnomalyKind::SequenceGap { previous, next } => format!(
133 "catalog sequence numbers are non-contiguous ({previous} -> {next}); consistent \
134 with a deleted intermediate shadow copy"
135 ),
136 AnomalyKind::StoreNonPersistent {
137 store_id,
138 attribute_flags,
139 } => format!(
140 "shadow copy {store_id} attribute flags 0x{attribute_flags:08x} lack the \
141 persistent bit; a non-persistent shadow copy does not survive a reboot"
142 ),
143 }
144 }
145
146 #[must_use]
148 pub fn mitre(&self) -> &'static [&'static str] {
149 match self {
150 AnomalyKind::NoShadowCopies | AnomalyKind::SequenceGap { .. } => &["T1490"],
151 AnomalyKind::StorePresent { .. } | AnomalyKind::StoreNonPersistent { .. } => &[],
152 }
153 }
154
155 fn subjects(&self) -> Vec<SubjectRef> {
156 match self {
157 AnomalyKind::StorePresent { store_id, .. }
158 | AnomalyKind::StoreNonPersistent { store_id, .. } => vec![SubjectRef {
159 scheme: "vss".to_string(),
160 kind: "shadow_copy".to_string(),
161 id: store_id.clone(),
162 label: None,
163 }],
164 AnomalyKind::NoShadowCopies | AnomalyKind::SequenceGap { .. } => Vec::new(),
165 }
166 }
167
168 fn evidence(&self) -> Vec<Evidence> {
169 match self {
170 AnomalyKind::NoShadowCopies => Vec::new(),
171 AnomalyKind::StorePresent {
172 store_id,
173 sequence,
174 volume_size,
175 creation_time,
176 } => vec![
177 evidence("store_id", store_id.clone()),
178 evidence("sequence", sequence.to_string()),
179 evidence("volume_size", volume_size.to_string()),
180 evidence("creation_time_filetime", creation_time.to_string()),
181 ],
182 AnomalyKind::SequenceGap { previous, next } => vec![
183 evidence("previous_sequence", previous.to_string()),
184 evidence("next_sequence", next.to_string()),
185 ],
186 AnomalyKind::StoreNonPersistent {
187 store_id,
188 attribute_flags,
189 } => vec![
190 evidence("store_id", store_id.clone()),
191 evidence("attribute_flags", format!("0x{attribute_flags:08x}")),
192 ],
193 }
194 }
195
196 fn timestamps(&self) -> Vec<Timestamp> {
197 match self {
198 AnomalyKind::StorePresent { creation_time, .. } => filetime_to_rfc3339(*creation_time)
199 .map(|value| {
200 vec![Timestamp {
201 value,
202 kind: "created".to_string(),
203 location: None,
204 }]
205 })
206 .unwrap_or_default(),
207 _ => Vec::new(),
208 }
209 }
210}
211
212fn evidence(field: &str, value: String) -> Evidence {
213 Evidence {
214 field: field.to_string(),
215 value,
216 location: None,
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Anomaly {
224 pub severity: Severity,
226 pub code: &'static str,
228 pub kind: AnomalyKind,
230 pub note: String,
232}
233
234impl Anomaly {
235 #[must_use]
237 pub fn new(kind: AnomalyKind) -> Self {
238 Anomaly {
239 severity: kind.severity(),
240 code: kind.code(),
241 note: kind.note(),
242 kind,
243 }
244 }
245
246 #[must_use]
249 pub fn to_finding(&self, source: Source) -> Finding {
250 let mut finding = Observation::to_finding(self, source);
251 for timestamp in self.kind.timestamps() {
252 finding.context.timestamps.push(timestamp);
253 }
254 finding
255 }
256}
257
258impl Observation for Anomaly {
259 fn severity(&self) -> Option<Severity> {
260 Some(self.severity)
261 }
262 fn code(&self) -> &'static str {
263 self.code
264 }
265 fn note(&self) -> String {
266 self.note.clone()
267 }
268 fn category(&self) -> Category {
269 self.kind.category()
270 }
271 fn subjects(&self) -> Vec<SubjectRef> {
272 self.kind.subjects()
273 }
274 fn evidence(&self) -> Vec<Evidence> {
275 self.kind.evidence()
276 }
277 fn mitre(&self) -> &'static [&'static str] {
278 self.kind.mitre()
279 }
280}
281
282#[must_use]
285pub fn filetime_to_rfc3339(filetime: u64) -> Option<String> {
286 if filetime == 0 || filetime < FILETIME_EPOCH_DIFF {
287 return None;
288 }
289 let unix_nanos = i128::from(filetime - FILETIME_EPOCH_DIFF) * 100;
290 jiff::Timestamp::from_nanosecond(unix_nanos)
291 .ok()
292 .map(|t| t.to_string())
293}
294
295#[must_use]
301pub fn audit<R: Read + Seek>(vol: &mut VssVolume<R>) -> Vec<Anomaly> {
302 let descriptors = vol.stores().to_vec();
303
304 if vol.has_vss_header() && descriptors.is_empty() {
307 return vec![Anomaly::new(AnomalyKind::NoShadowCopies)];
308 }
309
310 let mut out = Vec::new();
311 for descriptor in &descriptors {
312 out.push(Anomaly::new(AnomalyKind::StorePresent {
313 store_id: descriptor.store_id_string(),
314 sequence: descriptor.sequence,
315 volume_size: descriptor.volume_size,
316 creation_time: descriptor.creation_time,
317 }));
318 }
319
320 let mut sequences: Vec<u64> = descriptors.iter().map(|d| d.sequence).collect();
321 sequences.sort_unstable();
322 for (previous, next) in sequences
323 .iter()
324 .copied()
325 .zip(sequences.iter().copied().skip(1))
326 {
327 if next > previous.saturating_add(1) {
328 out.push(Anomaly::new(AnomalyKind::SequenceGap { previous, next }));
329 }
330 }
331
332 for (index, descriptor) in descriptors.iter().enumerate() {
333 if let Ok(info) = vol.store_info(index) {
334 if !info.attributes.is_persistent() {
335 out.push(Anomaly::new(AnomalyKind::StoreNonPersistent {
336 store_id: descriptor.store_id_string(),
337 attribute_flags: info.attributes.bits(),
338 }));
339 }
340 }
341 }
342
343 out
344}
345
346pub fn audit_findings<R: Read + Seek>(
349 vol: &mut VssVolume<R>,
350 scope: impl Into<String>,
351) -> Vec<Finding> {
352 let source = Source {
353 analyzer: ANALYZER.to_string(),
354 scope: scope.into(),
355 version: Some(env!("CARGO_PKG_VERSION").to_string()),
356 };
357 audit(vol)
358 .into_iter()
359 .map(|anomaly| anomaly.to_finding(source.clone()))
360 .collect()
361}