Skip to main content

vsc_forensic/
lib.rs

1//! # vsc-forensic — Volume Shadow Copy anomaly auditor
2//!
3//! Walks the shadow-copy stores decoded by [`vsc`] and emits severity-graded
4//! [`forensicnomicon::report::Finding`]s. Findings are OBSERVATIONS, never
5//! verdicts: an absence of shadow copies is reported as *consistent with* MITRE
6//! T1490 deletion **or** a volume that simply never had snapshots — the analyzer
7//! does not assert deletion.
8//!
9//! As the `[P^H]` disk-history layer, each enumerated store is a point-in-time
10//! materialization of the volume; the analyzer surfaces their presence, catalog
11//! sequence gaps (consistent with a deleted intermediate store), and notable
12//! store attributes.
13//!
14//! ```no_run
15//! use std::fs::File;
16//! use vsc::VssVolume;
17//!
18//! let mut vol = VssVolume::open(File::open("volume.raw")?)?;
19//! for anomaly in vsc_forensic::audit(&mut vol) {
20//!     println!("{}: {}", anomaly.code, anomaly.note);
21//! }
22//! # Ok::<(), Box<dyn std::error::Error>>(())
23//! ```
24
25#![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
38/// The producing analyzer name embedded in emitted findings' `Source`.
39pub const ANALYZER: &str = "vsc-forensic";
40
41/// Difference between the Windows FILETIME epoch (1601-01-01) and the Unix epoch
42/// (1970-01-01), in 100 ns units.
43const FILETIME_EPOCH_DIFF: u64 = 116_444_736_000_000_000;
44
45/// A classified VSS forensic anomaly.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum AnomalyKind {
48    /// The volume carries a VSS volume header but the catalog enumerated zero
49    /// stores — consistent with shadow-copy deletion (T1490) OR a volume that
50    /// never had snapshots. Not a determination of deletion.
51    NoShadowCopies,
52    /// A shadow-copy store is present.
53    StorePresent {
54        /// Store identifier GUID (canonical string).
55        store_id: String,
56        /// Catalog sequence number.
57        sequence: u64,
58        /// Shadow-copy volume size at snapshot time.
59        volume_size: u64,
60        /// Raw creation-time FILETIME.
61        creation_time: u64,
62    },
63    /// Catalog sequence numbers are non-contiguous — consistent with a deleted
64    /// intermediate shadow copy.
65    SequenceGap {
66        /// The lower sequence number bracketing the gap.
67        previous: u64,
68        /// The next present sequence number.
69        next: u64,
70    },
71    /// A store lacks the persistent attribute — a non-persistent shadow copy
72    /// does not survive a reboot, which is unusual for on-disk VSS.
73    StoreNonPersistent {
74        /// Store identifier GUID (canonical string).
75        store_id: String,
76        /// The store's attribute flags.
77        attribute_flags: u32,
78    },
79}
80
81impl AnomalyKind {
82    /// Severity — the single source of truth for this kind.
83    #[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    /// Stable, scheme-prefixed machine code (published contract).
93    #[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    /// Analytical lens.
104    #[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    /// Human-readable, "consistent with" note including the offending values.
114    #[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    /// MITRE ATT&CK technique ids this kind is consistent with.
147    #[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/// A VSS forensic anomaly: an observation graded by severity, with a stable code
221/// and note derived from its [`AnomalyKind`] so they cannot drift.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Anomaly {
224    /// Severity, derived from `kind`.
225    pub severity: Severity,
226    /// Stable machine-readable code, derived from `kind`.
227    pub code: &'static str,
228    /// The classified anomaly.
229    pub kind: AnomalyKind,
230    /// Human-readable note, derived from `kind`.
231    pub note: String,
232}
233
234impl Anomaly {
235    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
236    #[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    /// Assemble the canonical [`Finding`], adding the FILETIME-derived timestamps
247    /// the [`Observation`] trait cannot carry on its own.
248    #[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/// Convert a raw Windows FILETIME to an RFC 3339 string, or `None` when the value
283/// is zero or predates the Unix epoch.
284#[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/// Audit the shadow copies of a VSS volume, returning classified anomalies.
296///
297/// Reads each store's information to inspect attribute flags; a store whose
298/// information cannot be read is silently skipped for the attribute check (its
299/// presence is still reported).
300#[must_use]
301pub fn audit<R: Read + Seek>(vol: &mut VssVolume<R>) -> Vec<Anomaly> {
302    let descriptors = vol.stores().to_vec();
303
304    // A validated VSS header with an empty catalog is the one degrade-to-empty
305    // that IS a finding — never silently "no results".
306    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
346/// Audit a VSS volume and map each anomaly to a canonical [`Finding`], tagged
347/// with the producing [`Source`] (`scope` names the evidence, e.g. the volume).
348pub 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}