pub const FILETIME_EPOCH_OFFSET: u64 = 116_444_736_000_000_000;
pub fn filetime_to_unix_secs(ft: u64) -> Option<i64> {
if ft < FILETIME_EPOCH_OFFSET {
return None;
}
i64::try_from((ft - FILETIME_EPOCH_OFFSET) / 10_000_000).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum TemporalRelation {
Precedes,
Concurrent,
Follows,
ManipulationDetectable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TemporalHint {
pub artifact_id: &'static str,
pub correlates_with: &'static str,
pub relation: TemporalRelation,
pub hint: &'static str,
}
pub static TEMPORAL_TABLE: &[TemporalHint] = &[
TemporalHint {
artifact_id: "prefetch_dir",
correlates_with: "mft_file",
relation: TemporalRelation::ManipulationDetectable,
hint: "Compare $MFT timestamps of .pf files with Prefetch LastRun time; \
discrepancy indicates timestomping",
},
TemporalHint {
artifact_id: "prefetch_dir",
correlates_with: "evtx_security",
relation: TemporalRelation::Follows,
hint: "Process creation event 4688 should precede or match first Prefetch run time",
},
TemporalHint {
artifact_id: "userassist_exe",
correlates_with: "lnk_files",
relation: TemporalRelation::Concurrent,
hint: "UserAssist entry and LNK file timestamps should be close; \
divergence suggests manual artifact creation",
},
TemporalHint {
artifact_id: "lnk_files",
correlates_with: "jump_list_auto",
relation: TemporalRelation::Concurrent,
hint: "LNK timestamps and Jump List entries for same app should align within seconds",
},
TemporalHint {
artifact_id: "amcache_app_file",
correlates_with: "prefetch_dir",
relation: TemporalRelation::ManipulationDetectable,
hint: "Amcache compile time vs Prefetch first run; large gaps may indicate \
pre-compiled binaries dropped without execution",
},
TemporalHint {
artifact_id: "mft_file",
correlates_with: "prefetch_dir",
relation: TemporalRelation::ManipulationDetectable,
hint: "Compare $MFT $SI timestamps of .pf files with Prefetch LastRun time; \
divergence between $SI and $FN attributes indicates timestomping",
},
TemporalHint {
artifact_id: "mft_file",
correlates_with: "shimcache",
relation: TemporalRelation::ManipulationDetectable,
hint: "shimcache last modified vs $MFT $SI timestamps; compare for anti-forensics",
},
TemporalHint {
artifact_id: "evtx_security",
correlates_with: "evtx_system",
relation: TemporalRelation::Concurrent,
hint: "Security and System log timestamps should align; gaps indicate log clearing",
},
TemporalHint {
artifact_id: "ntds_dit",
correlates_with: "evtx_security",
relation: TemporalRelation::Follows,
hint: "NTDS password changes should correlate with 4723/4724 events",
},
TemporalHint {
artifact_id: "bam_user",
correlates_with: "prefetch_dir",
relation: TemporalRelation::ManipulationDetectable,
hint: "BAM last run vs Prefetch last run; discrepancy detects Prefetch manipulation",
},
TemporalHint {
artifact_id: "scheduled_tasks_dir",
correlates_with: "evtx_security",
relation: TemporalRelation::Follows,
hint: "Task creation (4698) should precede task XML on disk",
},
];
pub fn temporal_hints_for(artifact_id: &str) -> Vec<&'static TemporalHint> {
TEMPORAL_TABLE
.iter()
.filter(|h| h.artifact_id == artifact_id || h.correlates_with == artifact_id)
.collect()
}
pub fn correlation_pairs() -> Vec<(&'static str, &'static str)> {
TEMPORAL_TABLE
.iter()
.map(|h| (h.artifact_id, h.correlates_with))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filetime_epoch_offset_value() {
assert_eq!(FILETIME_EPOCH_OFFSET, 116_444_736_000_000_000u64);
}
#[test]
fn filetime_to_unix_secs_unix_epoch() {
assert_eq!(filetime_to_unix_secs(FILETIME_EPOCH_OFFSET), Some(0));
}
#[test]
fn filetime_to_unix_secs_year_2000() {
let ft = FILETIME_EPOCH_OFFSET + 946_684_800 * 10_000_000;
assert_eq!(filetime_to_unix_secs(ft), Some(946_684_800));
}
#[test]
fn filetime_to_unix_secs_pre_epoch_returns_none() {
assert_eq!(filetime_to_unix_secs(0), None);
assert_eq!(filetime_to_unix_secs(FILETIME_EPOCH_OFFSET - 1), None);
}
#[test]
fn table_nonempty() {
assert!(!TEMPORAL_TABLE.is_empty());
}
#[test]
fn prefetch_has_temporal_hints() {
let hints = temporal_hints_for("prefetch_dir");
assert!(
!hints.is_empty(),
"prefetch should have temporal correlation hints"
);
}
#[test]
fn mft_correlates_with_prefetch() {
let hints = temporal_hints_for("mft_file");
assert!(
hints.iter().any(|h| h.correlates_with == "prefetch_dir"),
"MFT should correlate with prefetch"
);
}
#[test]
fn unknown_returns_empty() {
assert!(temporal_hints_for("nonexistent").is_empty());
}
#[test]
fn correlation_pairs_nonempty() {
let pairs = correlation_pairs();
assert!(pairs.len() >= 5);
}
#[test]
fn all_artifact_ids_valid() {
use crate::catalog::CATALOG;
let ids: std::collections::HashSet<&str> = CATALOG.list().iter().map(|d| d.id).collect();
for hint in TEMPORAL_TABLE {
assert!(
ids.contains(hint.artifact_id),
"Unknown artifact_id: {}",
hint.artifact_id
);
assert!(
ids.contains(hint.correlates_with),
"Unknown correlates_with: {}",
hint.correlates_with
);
}
}
}