#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::io::{Read, Seek};
use forensicnomicon::report::{
Category, Evidence, Finding, Observation, Severity, Source, SubjectRef, Timestamp,
};
use vsc::VssVolume;
#[cfg(test)]
mod tests;
pub const ANALYZER: &str = "vsc-forensic";
const FILETIME_EPOCH_DIFF: u64 = 116_444_736_000_000_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnomalyKind {
NoShadowCopies,
StorePresent {
store_id: String,
sequence: u64,
volume_size: u64,
creation_time: u64,
},
SequenceGap {
previous: u64,
next: u64,
},
StoreNonPersistent {
store_id: String,
attribute_flags: u32,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::StorePresent { .. } => Severity::Info,
AnomalyKind::NoShadowCopies | AnomalyKind::StoreNonPersistent { .. } => Severity::Low,
AnomalyKind::SequenceGap { .. } => Severity::Medium,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::NoShadowCopies => "VSC-NO-SHADOW-COPIES",
AnomalyKind::StorePresent { .. } => "VSC-STORE-PRESENT",
AnomalyKind::SequenceGap { .. } => "VSC-SEQUENCE-GAP",
AnomalyKind::StoreNonPersistent { .. } => "VSC-STORE-NON-PERSISTENT",
}
}
#[must_use]
pub fn category(&self) -> Category {
match self {
AnomalyKind::NoShadowCopies | AnomalyKind::StorePresent { .. } => Category::History,
AnomalyKind::SequenceGap { .. } => Category::Residue,
AnomalyKind::StoreNonPersistent { .. } => Category::Provenance,
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::NoShadowCopies => {
"the volume carries a VSS volume header but the catalog \
enumerated zero shadow-copy stores; consistent with shadow-copy deletion (MITRE \
T1490) or a volume that never had snapshots — not a determination of deletion"
.to_string()
}
AnomalyKind::StorePresent {
store_id,
sequence,
volume_size,
..
} => format!(
"shadow copy {store_id} is present (catalog sequence {sequence}, shadow volume \
size {volume_size} bytes)"
),
AnomalyKind::SequenceGap { previous, next } => format!(
"catalog sequence numbers are non-contiguous ({previous} -> {next}); consistent \
with a deleted intermediate shadow copy"
),
AnomalyKind::StoreNonPersistent {
store_id,
attribute_flags,
} => format!(
"shadow copy {store_id} attribute flags 0x{attribute_flags:08x} lack the \
persistent bit; a non-persistent shadow copy does not survive a reboot"
),
}
}
#[must_use]
pub fn mitre(&self) -> &'static [&'static str] {
match self {
AnomalyKind::NoShadowCopies | AnomalyKind::SequenceGap { .. } => &["T1490"],
AnomalyKind::StorePresent { .. } | AnomalyKind::StoreNonPersistent { .. } => &[],
}
}
fn subjects(&self) -> Vec<SubjectRef> {
match self {
AnomalyKind::StorePresent { store_id, .. }
| AnomalyKind::StoreNonPersistent { store_id, .. } => vec![SubjectRef {
scheme: "vss".to_string(),
kind: "shadow_copy".to_string(),
id: store_id.clone(),
label: None,
}],
AnomalyKind::NoShadowCopies | AnomalyKind::SequenceGap { .. } => Vec::new(),
}
}
fn evidence(&self) -> Vec<Evidence> {
match self {
AnomalyKind::NoShadowCopies => Vec::new(),
AnomalyKind::StorePresent {
store_id,
sequence,
volume_size,
creation_time,
} => vec![
evidence("store_id", store_id.clone()),
evidence("sequence", sequence.to_string()),
evidence("volume_size", volume_size.to_string()),
evidence("creation_time_filetime", creation_time.to_string()),
],
AnomalyKind::SequenceGap { previous, next } => vec![
evidence("previous_sequence", previous.to_string()),
evidence("next_sequence", next.to_string()),
],
AnomalyKind::StoreNonPersistent {
store_id,
attribute_flags,
} => vec![
evidence("store_id", store_id.clone()),
evidence("attribute_flags", format!("0x{attribute_flags:08x}")),
],
}
}
fn timestamps(&self) -> Vec<Timestamp> {
match self {
AnomalyKind::StorePresent { creation_time, .. } => filetime_to_rfc3339(*creation_time)
.map(|value| {
vec![Timestamp {
value,
kind: "created".to_string(),
location: None,
}]
})
.unwrap_or_default(),
_ => Vec::new(),
}
}
}
fn evidence(field: &str, value: String) -> Evidence {
Evidence {
field: field.to_string(),
value,
location: None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
#[must_use]
pub fn to_finding(&self, source: Source) -> Finding {
let mut finding = Observation::to_finding(self, source);
for timestamp in self.kind.timestamps() {
finding.context.timestamps.push(timestamp);
}
finding
}
}
impl Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn category(&self) -> Category {
self.kind.category()
}
fn subjects(&self) -> Vec<SubjectRef> {
self.kind.subjects()
}
fn evidence(&self) -> Vec<Evidence> {
self.kind.evidence()
}
fn mitre(&self) -> &'static [&'static str] {
self.kind.mitre()
}
}
#[must_use]
pub fn filetime_to_rfc3339(filetime: u64) -> Option<String> {
if filetime == 0 || filetime < FILETIME_EPOCH_DIFF {
return None;
}
let unix_nanos = i128::from(filetime - FILETIME_EPOCH_DIFF) * 100;
jiff::Timestamp::from_nanosecond(unix_nanos)
.ok()
.map(|t| t.to_string())
}
#[must_use]
pub fn audit<R: Read + Seek>(vol: &mut VssVolume<R>) -> Vec<Anomaly> {
let descriptors = vol.stores().to_vec();
if vol.has_vss_header() && descriptors.is_empty() {
return vec![Anomaly::new(AnomalyKind::NoShadowCopies)];
}
let mut out = Vec::new();
for descriptor in &descriptors {
out.push(Anomaly::new(AnomalyKind::StorePresent {
store_id: descriptor.store_id_string(),
sequence: descriptor.sequence,
volume_size: descriptor.volume_size,
creation_time: descriptor.creation_time,
}));
}
let mut sequences: Vec<u64> = descriptors.iter().map(|d| d.sequence).collect();
sequences.sort_unstable();
for (previous, next) in sequences
.iter()
.copied()
.zip(sequences.iter().copied().skip(1))
{
if next > previous.saturating_add(1) {
out.push(Anomaly::new(AnomalyKind::SequenceGap { previous, next }));
}
}
for (index, descriptor) in descriptors.iter().enumerate() {
if let Ok(info) = vol.store_info(index) {
if !info.attributes.is_persistent() {
out.push(Anomaly::new(AnomalyKind::StoreNonPersistent {
store_id: descriptor.store_id_string(),
attribute_flags: info.attributes.bits(),
}));
}
}
}
out
}
pub fn audit_findings<R: Read + Seek>(
vol: &mut VssVolume<R>,
scope: impl Into<String>,
) -> Vec<Finding> {
let source = Source {
analyzer: ANALYZER.to_string(),
scope: scope.into(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
audit(vol)
.into_iter()
.map(|anomaly| anomaly.to_finding(source.clone()))
.collect()
}