use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use ntfs_core::usn::{UsnReason, UsnRecord};
#[derive(Debug, Clone)]
pub struct SecureDeletionIndicator {
pub pattern: SecureDeletionPattern,
pub filenames: Vec<String>,
pub time_start: DateTime<Utc>,
pub time_end: DateTime<Utc>,
pub confidence: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecureDeletionPattern {
SDelete,
BulkTempDeletion,
CipherWipe,
}
#[must_use]
pub fn detect_secure_deletion(records: &[UsnRecord]) -> Vec<SecureDeletionIndicator> {
let mut indicators = Vec::new();
let sdelete_patterns = detect_sdelete_patterns(records);
indicators.extend(sdelete_patterns);
let temp_indicators = detect_bulk_temp_deletion(records);
indicators.extend(temp_indicators);
indicators
}
fn detect_sdelete_patterns(records: &[UsnRecord]) -> Vec<SecureDeletionIndicator> {
let mut indicators = Vec::new();
let mut sdelete_events: Vec<&UsnRecord> = Vec::new();
for record in records {
if is_sdelete_filename(&record.filename)
&& (record.reason.contains(UsnReason::FILE_CREATE)
|| record.reason.contains(UsnReason::FILE_DELETE))
{
sdelete_events.push(record);
}
}
if sdelete_events.len() < 3 {
return indicators;
}
let mut groups: Vec<Vec<&UsnRecord>> = Vec::new();
let mut current_group: Vec<&UsnRecord> = vec![sdelete_events[0]];
for event in &sdelete_events[1..] {
let within_window = current_group
.last()
.is_some_and(|last| event.timestamp - last.timestamp <= Duration::seconds(60));
if within_window {
current_group.push(event);
} else {
if current_group.len() >= 3 {
groups.push(std::mem::take(&mut current_group));
} else {
current_group.clear();
}
current_group.push(event);
}
}
if current_group.len() >= 3 {
groups.push(current_group);
}
for group in groups {
let filenames: Vec<String> = group.iter().map(|r| r.filename.clone()).collect();
let Some(time_start) = group.first().map(|r| r.timestamp) else {
continue; };
let Some(time_end) = group.last().map(|r| r.timestamp) else {
continue; };
let has_creates = group
.iter()
.any(|r| r.reason.contains(UsnReason::FILE_CREATE));
let has_deletes = group
.iter()
.any(|r| r.reason.contains(UsnReason::FILE_DELETE));
let confidence = if has_creates && has_deletes { 0.9 } else { 0.6 };
indicators.push(SecureDeletionIndicator {
pattern: SecureDeletionPattern::SDelete,
filenames,
time_start,
time_end,
confidence,
});
}
indicators
}
fn is_sdelete_filename(name: &str) -> bool {
let base = name.split('.').next().unwrap_or(name);
if base.len() < 3 {
return false;
}
let Some(first) = base.chars().next() else {
return false; };
base.chars().all(|c| c == first) && (first.is_ascii_uppercase() || first.is_ascii_digit())
}
fn detect_bulk_temp_deletion(records: &[UsnRecord]) -> Vec<SecureDeletionIndicator> {
let mut indicators = Vec::new();
let tmp_deletes: Vec<&UsnRecord> = records
.iter()
.filter(|r| {
r.reason.contains(UsnReason::FILE_DELETE) && r.filename.to_lowercase().ends_with(".tmp")
})
.collect();
if tmp_deletes.len() < 10 {
return indicators;
}
let mut groups: Vec<Vec<&UsnRecord>> = Vec::new();
let mut current_group: Vec<&UsnRecord> = vec![tmp_deletes[0]];
for event in &tmp_deletes[1..] {
let within_window = current_group
.last()
.is_some_and(|last| event.timestamp - last.timestamp <= Duration::seconds(30));
if within_window {
current_group.push(event);
} else {
if current_group.len() >= 10 {
groups.push(std::mem::take(&mut current_group));
} else {
current_group.clear();
}
current_group.push(event);
}
}
if current_group.len() >= 10 {
groups.push(current_group);
}
for group in groups {
let Some(time_start) = group.first().map(|r| r.timestamp) else {
continue; };
let Some(time_end) = group.last().map(|r| r.timestamp) else {
continue; };
indicators.push(SecureDeletionIndicator {
pattern: SecureDeletionPattern::BulkTempDeletion,
filenames: group.iter().map(|r| r.filename.clone()).collect(),
time_start,
time_end,
confidence: 0.7,
});
}
indicators
}
#[derive(Debug, Clone)]
pub struct JournalClearingResult {
pub clearing_detected: bool,
pub first_usn: Option<i64>,
pub timestamp_gaps: Vec<TimestampGap>,
pub confidence: f64,
}
#[derive(Debug, Clone)]
pub struct TimestampGap {
pub before: DateTime<Utc>,
pub after: DateTime<Utc>,
pub gap_duration: Duration,
pub usn_before: i64,
pub usn_after: i64,
}
#[must_use]
pub fn detect_journal_clearing(records: &[UsnRecord]) -> JournalClearingResult {
let Some(first) = records.first() else {
return JournalClearingResult {
clearing_detected: false,
first_usn: None,
timestamp_gaps: Vec::new(),
confidence: 0.0,
};
};
let first_usn = first.usn;
let mut confidence = 0.0;
const USN_CLEARING_THRESHOLD: i64 = 1_073_741_824; let high_usn = first_usn > USN_CLEARING_THRESHOLD;
if high_usn {
confidence += 0.5;
}
let mut timestamp_gaps = Vec::new();
let gap_threshold = Duration::hours(24);
for window in records.windows(2) {
let gap = window[1].timestamp - window[0].timestamp;
if gap > gap_threshold {
timestamp_gaps.push(TimestampGap {
before: window[0].timestamp,
after: window[1].timestamp,
gap_duration: gap,
usn_before: window[0].usn,
usn_after: window[1].usn,
});
}
}
if !timestamp_gaps.is_empty() {
let gap_factor = (timestamp_gaps.len() as f64 * 0.2).min(0.5);
confidence += gap_factor;
}
let clearing_detected = confidence >= 0.4;
JournalClearingResult {
clearing_detected,
first_usn: Some(first_usn),
timestamp_gaps,
confidence,
}
}
#[derive(Debug, Clone)]
pub struct RansomwareIndicator {
pub extension: String,
pub affected_count: usize,
pub sample_filenames: Vec<String>,
pub time_start: DateTime<Utc>,
pub time_end: DateTime<Utc>,
pub confidence: f64,
}
const RANSOMWARE_EXTENSIONS: &[&str] = &[
".encrypted",
".locked",
".crypto",
".crypt",
".enc",
".locky",
".cerber",
".zepto",
".odin",
".thor",
".aesir",
".zzzzz",
".micro",
".crypted",
".crinf",
".r5a",
".xrtn",
".xtbl",
".crypz",
".cryp1",
".ransom",
".wallet",
".onion",
".wncry",
".wcry",
".wncryt",
];
#[must_use]
pub fn detect_ransomware_patterns(records: &[UsnRecord]) -> Vec<RansomwareIndicator> {
let mut indicators = Vec::new();
indicators.extend(detect_known_ransomware_extensions(records));
indicators.extend(detect_mass_rename_patterns(records));
indicators
}
fn detect_known_ransomware_extensions(records: &[UsnRecord]) -> Vec<RansomwareIndicator> {
let mut indicators = Vec::new();
let mut extension_groups: HashMap<String, Vec<&UsnRecord>> = HashMap::new();
for record in records {
if record.reason.contains(UsnReason::RENAME_NEW_NAME) {
let lower = record.filename.to_lowercase();
for ext in RANSOMWARE_EXTENSIONS {
if lower.ends_with(ext) {
extension_groups
.entry((*ext).to_string())
.or_default()
.push(record);
break;
}
}
}
}
for (ext, group) in &extension_groups {
if group.len() >= 3 {
let Some(time_start) = group.iter().map(|r| r.timestamp).min() else {
continue; };
let Some(time_end) = group.iter().map(|r| r.timestamp).max() else {
continue; };
let sample: Vec<String> = group.iter().take(10).map(|r| r.filename.clone()).collect();
let confidence = if group.len() >= 20 {
0.95
} else if group.len() >= 10 {
0.85
} else {
0.6
};
indicators.push(RansomwareIndicator {
extension: ext.clone(),
affected_count: group.len(),
sample_filenames: sample,
time_start,
time_end,
confidence,
});
}
}
indicators
}
fn detect_mass_rename_patterns(records: &[UsnRecord]) -> Vec<RansomwareIndicator> {
let mut indicators = Vec::new();
let rename_records: Vec<&UsnRecord> = records
.iter()
.filter(|r| r.reason.contains(UsnReason::RENAME_NEW_NAME))
.collect();
if rename_records.len() < 20 {
return indicators;
}
let mut ext_groups: HashMap<String, Vec<&UsnRecord>> = HashMap::new();
for record in &rename_records {
if let Some(dot_pos) = record.filename.rfind('.') {
let ext = record.filename[dot_pos..].to_lowercase();
if !is_common_extension(&ext) {
let lower = ext.clone();
let is_known = RANSOMWARE_EXTENSIONS.iter().any(|&re| lower == re);
if !is_known {
ext_groups.entry(ext).or_default().push(record);
}
}
}
}
for (ext, group) in &ext_groups {
if group.len() >= 20 {
let Some(time_start) = group.iter().map(|r| r.timestamp).min() else {
continue; };
let Some(time_end) = group.iter().map(|r| r.timestamp).max() else {
continue; };
let duration = time_end - time_start;
if duration <= Duration::minutes(10) {
let sample: Vec<String> =
group.iter().take(10).map(|r| r.filename.clone()).collect();
indicators.push(RansomwareIndicator {
extension: ext.clone(),
affected_count: group.len(),
sample_filenames: sample,
time_start,
time_end,
confidence: 0.75,
});
}
}
}
indicators
}
fn is_common_extension(ext: &str) -> bool {
matches!(
ext,
".txt"
| ".doc"
| ".docx"
| ".xls"
| ".xlsx"
| ".pdf"
| ".jpg"
| ".jpeg"
| ".png"
| ".gif"
| ".mp3"
| ".mp4"
| ".avi"
| ".zip"
| ".rar"
| ".exe"
| ".dll"
| ".sys"
| ".log"
| ".tmp"
| ".bak"
| ".html"
| ".htm"
| ".css"
| ".js"
| ".py"
| ".rs"
| ".c"
| ".h"
| ".cpp"
| ".java"
| ".xml"
| ".json"
| ".csv"
| ".ppt"
| ".pptx"
)
}
#[derive(Debug, Clone)]
pub struct TimestompIndicator {
pub filename: String,
pub mft_entry: u64,
pub change_timestamp: DateTime<Utc>,
pub has_nearby_data_change: bool,
pub confidence: f64,
}
#[must_use]
pub fn detect_timestomping(records: &[UsnRecord]) -> Vec<TimestompIndicator> {
let mut indicators = Vec::new();
let mut entry_events: HashMap<u64, Vec<&UsnRecord>> = HashMap::new();
for record in records {
entry_events
.entry(record.mft_entry)
.or_default()
.push(record);
}
for (&mft_entry, events) in &entry_events {
for (i, event) in events.iter().enumerate() {
if !event.reason.contains(UsnReason::BASIC_INFO_CHANGE) {
continue;
}
let has_nearby_data_change = events.iter().enumerate().any(|(j, other)| {
if i == j {
return false;
}
let time_diff = if other.timestamp >= event.timestamp {
other.timestamp - event.timestamp
} else {
event.timestamp - other.timestamp
};
if time_diff > Duration::seconds(60) {
return false;
}
other.reason.contains(UsnReason::DATA_OVERWRITE)
|| other.reason.contains(UsnReason::DATA_EXTEND)
|| other.reason.contains(UsnReason::DATA_TRUNCATION)
|| other.reason.contains(UsnReason::FILE_CREATE)
});
if !has_nearby_data_change {
let reason_without_close = event.reason & !UsnReason::CLOSE;
let is_isolated = reason_without_close == UsnReason::BASIC_INFO_CHANGE;
let confidence = if is_isolated { 0.8 } else { 0.5 };
indicators.push(TimestompIndicator {
filename: event.filename.clone(),
mft_entry,
change_timestamp: event.timestamp,
has_nearby_data_change: false,
confidence,
});
}
}
}
indicators
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Duration, Utc};
use ntfs_core::usn::{FileAttributes, UsnReason, UsnRecord};
fn make_record(
mft_entry: u64,
filename: &str,
reason: UsnReason,
timestamp: DateTime<Utc>,
usn: i64,
) -> UsnRecord {
UsnRecord {
mft_entry,
mft_sequence: 1,
parent_mft_entry: 5,
parent_mft_sequence: 1,
usn,
timestamp,
reason,
filename: filename.to_string(),
file_attributes: FileAttributes::ARCHIVE,
source_info: 0,
security_id: 0,
major_version: 2,
}
}
fn ts(secs_offset: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000 + secs_offset, 0).unwrap()
}
#[test]
fn test_detect_sdelete_pattern() {
let records = vec![
make_record(100, "AAAAAAA", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(100, "AAAAAAA", UsnReason::FILE_DELETE, ts(1), 1100),
make_record(101, "ZZZZZZZ", UsnReason::FILE_CREATE, ts(2), 1200),
make_record(101, "ZZZZZZZ", UsnReason::FILE_DELETE, ts(3), 1300),
make_record(102, "0000000", UsnReason::FILE_CREATE, ts(4), 1400),
make_record(102, "0000000", UsnReason::FILE_DELETE, ts(5), 1500),
];
let indicators = detect_secure_deletion(&records);
assert!(!indicators.is_empty());
assert_eq!(indicators[0].pattern, SecureDeletionPattern::SDelete);
assert!(indicators[0].confidence >= 0.9);
}
#[test]
fn test_sdelete_not_triggered_by_normal_files() {
let records = vec![
make_record(100, "document.docx", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(101, "report.pdf", UsnReason::FILE_CREATE, ts(1), 1100),
make_record(102, "image.png", UsnReason::FILE_DELETE, ts(2), 1200),
];
let indicators = detect_secure_deletion(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_detect_bulk_temp_deletion() {
let mut records = Vec::new();
for i in 0..15 {
records.push(make_record(
100 + i,
&format!("tmp{i:04}.tmp"),
UsnReason::FILE_DELETE,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_secure_deletion(&records);
assert!(!indicators.is_empty());
assert_eq!(
indicators[0].pattern,
SecureDeletionPattern::BulkTempDeletion
);
}
#[test]
fn test_no_bulk_temp_with_few_files() {
let records = vec![
make_record(100, "tmp001.tmp", UsnReason::FILE_DELETE, ts(0), 1000),
make_record(101, "tmp002.tmp", UsnReason::FILE_DELETE, ts(1), 1100),
];
let indicators = detect_secure_deletion(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_detect_high_starting_usn() {
let records = vec![
make_record(
100,
"file.txt",
UsnReason::FILE_CREATE,
ts(0),
2_000_000_000, ),
make_record(
101,
"file2.txt",
UsnReason::FILE_CREATE,
ts(1),
2_000_001_000,
),
];
let result = detect_journal_clearing(&records);
assert!(result.clearing_detected);
assert!(result.confidence >= 0.4);
assert_eq!(result.first_usn, Some(2_000_000_000));
}
#[test]
fn test_detect_timestamp_gap() {
let records = vec![
make_record(100, "before.txt", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(
101,
"after.txt",
UsnReason::FILE_CREATE,
ts(48 * 3600),
1100,
),
];
let result = detect_journal_clearing(&records);
assert!(!result.timestamp_gaps.is_empty());
assert!(result.timestamp_gaps[0].gap_duration > Duration::hours(24));
}
#[test]
fn test_no_clearing_for_normal_journal() {
let records = vec![
make_record(100, "a.txt", UsnReason::FILE_CREATE, ts(0), 100),
make_record(101, "b.txt", UsnReason::FILE_CREATE, ts(60), 200),
make_record(102, "c.txt", UsnReason::FILE_CREATE, ts(120), 300),
];
let result = detect_journal_clearing(&records);
assert!(!result.clearing_detected);
assert!(result.timestamp_gaps.is_empty());
}
#[test]
fn test_clearing_empty_records() {
let result = detect_journal_clearing(&[]);
assert!(!result.clearing_detected);
assert!(result.first_usn.is_none());
}
#[test]
fn test_detect_known_ransomware_extension() {
let mut records = Vec::new();
for i in 0..5 {
records.push(make_record(
100 + i,
&format!("document{i}.docx.encrypted"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
assert!(!indicators.is_empty());
assert_eq!(indicators[0].extension, ".encrypted");
assert_eq!(indicators[0].affected_count, 5);
}
#[test]
fn test_detect_mass_rename_unknown_extension() {
let mut records = Vec::new();
for i in 0..25 {
records.push(make_record(
100 + i,
&format!("file{i}.xyz_ransom"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
assert!(!indicators.is_empty());
}
#[test]
fn test_no_ransomware_for_normal_renames() {
let records = vec![
make_record(100, "doc1.docx", UsnReason::RENAME_NEW_NAME, ts(0), 1000),
make_record(101, "image.png", UsnReason::RENAME_NEW_NAME, ts(100), 1100),
make_record(102, "report.pdf", UsnReason::RENAME_NEW_NAME, ts(200), 1200),
];
let indicators = detect_ransomware_patterns(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_ransomware_multiple_known_extensions() {
let mut records = Vec::new();
for i in 0..5 {
records.push(make_record(
100 + i,
&format!("file{i}.locked"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
for i in 0..4 {
records.push(make_record(
200 + i,
&format!("photo{i}.crypto"),
UsnReason::RENAME_NEW_NAME,
ts(100 + i as i64),
2000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
let locked_indicators: Vec<_> = indicators
.iter()
.filter(|i| i.extension == ".locked")
.collect();
assert!(!locked_indicators.is_empty());
}
#[test]
fn test_detect_isolated_basic_info_change() {
let records = vec![make_record(
100,
"suspicious.exe",
UsnReason::BASIC_INFO_CHANGE,
ts(1000),
5000,
)];
let indicators = detect_timestomping(&records);
assert!(!indicators.is_empty());
assert_eq!(indicators[0].filename, "suspicious.exe");
assert!(!indicators[0].has_nearby_data_change);
assert!(indicators[0].confidence >= 0.7);
}
#[test]
fn test_no_timestomp_with_data_change() {
let records = vec![
make_record(100, "normal.docx", UsnReason::DATA_OVERWRITE, ts(999), 4900),
make_record(
100,
"normal.docx",
UsnReason::BASIC_INFO_CHANGE,
ts(1000),
5000,
),
];
let indicators = detect_timestomping(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_timestomp_with_distant_data_change() {
let records = vec![
make_record(
100,
"suspicious.exe",
UsnReason::DATA_OVERWRITE,
ts(0),
1000,
),
make_record(
100,
"suspicious.exe",
UsnReason::BASIC_INFO_CHANGE,
ts(120), 5000,
),
];
let indicators = detect_timestomping(&records);
assert!(!indicators.is_empty());
}
#[test]
fn test_timestomp_multiple_files() {
let records = vec![
make_record(
100,
"malware1.exe",
UsnReason::BASIC_INFO_CHANGE,
ts(0),
1000,
),
make_record(
200,
"malware2.dll",
UsnReason::BASIC_INFO_CHANGE,
ts(5),
1500,
),
make_record(300, "normal.txt", UsnReason::DATA_OVERWRITE, ts(10), 2000),
make_record(
300,
"normal.txt",
UsnReason::BASIC_INFO_CHANGE,
ts(11),
2100,
),
];
let indicators = detect_timestomping(&records);
let flagged_files: Vec<&str> = indicators.iter().map(|i| i.filename.as_str()).collect();
assert!(flagged_files.contains(&"malware1.exe"));
assert!(flagged_files.contains(&"malware2.dll"));
assert!(!flagged_files.contains(&"normal.txt"));
}
#[test]
fn test_no_timestomp_on_create() {
let records = vec![
make_record(100, "newfile.txt", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(
100,
"newfile.txt",
UsnReason::BASIC_INFO_CHANGE,
ts(1),
1100,
),
];
let indicators = detect_timestomping(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_is_sdelete_filename_short() {
assert!(!is_sdelete_filename("AB"));
assert!(!is_sdelete_filename("A"));
assert!(!is_sdelete_filename(""));
}
#[test]
fn test_is_sdelete_filename_mixed_chars() {
assert!(!is_sdelete_filename("ABCDEF"));
assert!(!is_sdelete_filename("aaaaaa")); }
#[test]
fn test_is_sdelete_filename_with_extension() {
assert!(is_sdelete_filename("AAAA.txt"));
assert!(is_sdelete_filename("ZZZZZ.dat"));
assert!(is_sdelete_filename("00000.bin"));
}
#[test]
fn test_is_common_extension() {
assert!(is_common_extension(".txt"));
assert!(is_common_extension(".exe"));
assert!(is_common_extension(".dll"));
assert!(is_common_extension(".pdf"));
assert!(!is_common_extension(".xyz_ransom"));
assert!(!is_common_extension(".custom"));
}
#[test]
fn test_sdelete_only_creates_lower_confidence() {
let records = vec![
make_record(100, "AAAAAAA", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(101, "BBBBBBB", UsnReason::FILE_CREATE, ts(1), 1100),
make_record(102, "CCCCCCC", UsnReason::FILE_CREATE, ts(2), 1200),
];
let indicators = detect_secure_deletion(&records);
assert!(!indicators.is_empty());
assert!(indicators[0].confidence < 0.9);
}
#[test]
fn test_sdelete_events_spread_over_time() {
let records = vec![
make_record(100, "AAAAAAA", UsnReason::FILE_CREATE, ts(0), 1000),
make_record(101, "AAAAAAA", UsnReason::FILE_DELETE, ts(1), 1100),
make_record(102, "BBBBBBB", UsnReason::FILE_CREATE, ts(120), 1200),
make_record(103, "BBBBBBB", UsnReason::FILE_DELETE, ts(121), 1300),
];
let indicators = detect_secure_deletion(&records);
let sdelete_indicators: Vec<_> = indicators
.iter()
.filter(|i| i.pattern == SecureDeletionPattern::SDelete)
.collect();
assert!(sdelete_indicators.is_empty());
}
#[test]
fn test_bulk_temp_deletion_spread_over_time() {
let mut records = Vec::new();
for i in 0..5 {
records.push(make_record(
100 + i,
&format!("tmp{i:04}.tmp"),
UsnReason::FILE_DELETE,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
for i in 0..5 {
let n = 100 + i;
records.push(make_record(
200 + i,
&format!("tmp{n:04}.tmp"),
UsnReason::FILE_DELETE,
ts(60 + i as i64),
2000 + (i as i64) * 100,
));
}
let indicators = detect_secure_deletion(&records);
let bulk = indicators
.iter()
.filter(|i| i.pattern == SecureDeletionPattern::BulkTempDeletion)
.count();
assert_eq!(bulk, 0);
}
#[test]
fn test_mass_rename_with_common_extension_ignored() {
let mut records = Vec::new();
for i in 0..25 {
records.push(make_record(
100 + i,
&format!("file{i}.txt"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_ransomware_high_count_high_confidence() {
let mut records = Vec::new();
for i in 0..25 {
records.push(make_record(
100 + i,
&format!("document{i}.docx.encrypted"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
assert!(!indicators.is_empty());
let encrypted_ind = indicators
.iter()
.find(|i| i.extension == ".encrypted")
.unwrap();
assert!(encrypted_ind.confidence >= 0.95);
}
#[test]
fn test_ransomware_medium_count_medium_confidence() {
let mut records = Vec::new();
for i in 0..12 {
records.push(make_record(
100 + i,
&format!("file{i}.locked"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
let locked = indicators
.iter()
.find(|i| i.extension == ".locked")
.unwrap();
assert!((locked.confidence - 0.85).abs() < 0.01);
}
#[test]
fn test_mass_rename_over_long_time_not_ransomware() {
let mut records = Vec::new();
for i in 0..25 {
records.push(make_record(
100 + i,
&format!("file{i}.xyz_spread"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64 * 60), 1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
let spread = indicators
.iter()
.filter(|i| i.extension == ".xyz_spread")
.count();
assert_eq!(spread, 0);
}
#[test]
fn test_detect_journal_clearing_multiple_gaps() {
let records = vec![
make_record(100, "a.txt", UsnReason::FILE_CREATE, ts(0), 100),
make_record(101, "b.txt", UsnReason::FILE_CREATE, ts(25 * 3600), 200),
make_record(102, "c.txt", UsnReason::FILE_CREATE, ts(50 * 3600), 300),
make_record(103, "d.txt", UsnReason::FILE_CREATE, ts(75 * 3600), 400),
];
let result = detect_journal_clearing(&records);
assert_eq!(result.timestamp_gaps.len(), 3);
assert!(result.confidence > 0.0);
}
#[test]
fn test_timestomp_basic_info_change_with_close() {
let records = vec![make_record(
100,
"stomped.exe",
UsnReason::BASIC_INFO_CHANGE | UsnReason::CLOSE | UsnReason::SECURITY_CHANGE,
ts(1000),
5000,
)];
let indicators = detect_timestomping(&records);
assert!(!indicators.is_empty());
assert!(indicators[0].confidence <= 0.5);
}
#[test]
fn test_sdelete_grouping_splits_on_time_gap() {
let mut records = Vec::new();
for i in 0..4u64 {
records.push(make_record(
100 + i,
&"A".repeat(7),
UsnReason::FILE_CREATE,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
records.push(make_record(
200,
&"B".repeat(7),
UsnReason::FILE_DELETE,
ts(124),
2000,
));
records.push(make_record(
201,
&"B".repeat(7),
UsnReason::FILE_DELETE,
ts(125),
2100,
));
for i in 0..3u64 {
records.push(make_record(
300 + i,
&"C".repeat(7),
UsnReason::FILE_CREATE,
ts(300 + i as i64),
3000 + (i as i64) * 100,
));
}
let indicators = detect_secure_deletion(&records);
let sdelete_indicators: Vec<_> = indicators
.iter()
.filter(|i| i.pattern == SecureDeletionPattern::SDelete)
.collect();
assert!(!sdelete_indicators.is_empty());
}
#[test]
fn test_bulk_temp_deletion_grouping_splits_on_time_gap() {
let mut records = Vec::new();
for i in 0..12u64 {
records.push(make_record(
100 + i,
&format!("tmp{i:04}.tmp"),
UsnReason::FILE_DELETE,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
for i in 0..5u64 {
records.push(make_record(
200 + i,
&format!("tmpB{i:04}.tmp"),
UsnReason::FILE_DELETE,
ts(72 + i as i64),
2000 + (i as i64) * 100,
));
}
for i in 0..10u64 {
records.push(make_record(
300 + i,
&format!("tmpC{i:04}.tmp"),
UsnReason::FILE_DELETE,
ts(200 + i as i64),
3000 + (i as i64) * 100,
));
}
let indicators = detect_secure_deletion(&records);
let bulk_indicators: Vec<_> = indicators
.iter()
.filter(|i| i.pattern == SecureDeletionPattern::BulkTempDeletion)
.collect();
assert!(!bulk_indicators.is_empty());
}
#[test]
fn test_timestomping_other_before_event() {
let records = vec![
make_record(
100,
"stomped.exe",
UsnReason::BASIC_INFO_CHANGE,
ts(1000),
5000,
),
make_record(
101,
"other.txt",
UsnReason::SECURITY_CHANGE,
ts(1010), 5100,
),
];
let indicators = detect_timestomping(&records);
assert!(!indicators.is_empty());
assert_eq!(indicators[0].filename, "stomped.exe");
}
#[test]
fn test_ransomware_renames_without_extension() {
let mut records = Vec::new();
for i in 0..25 {
records.push(make_record(
100 + i,
&format!("file{i}"), UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_ransomware_patterns(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_timestomp_other_timestamp_after_event() {
let records = vec![
make_record(100, "file.exe", UsnReason::BASIC_INFO_CHANGE, ts(100), 5000),
make_record(
100,
"file.exe",
UsnReason::DATA_OVERWRITE,
ts(110), 5100,
),
];
let indicators = detect_timestomping(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_known_ext_rename_without_matching_extension() {
let records = vec![
make_record(100, "renamed.docx", UsnReason::RENAME_NEW_NAME, ts(0), 1000),
make_record(101, "moved.pdf", UsnReason::RENAME_NEW_NAME, ts(1), 1100),
make_record(102, "data.bin", UsnReason::RENAME_NEW_NAME, ts(2), 1200),
make_record(103, "plain.txt", UsnReason::FILE_CREATE, ts(3), 1300),
];
let indicators = detect_known_ransomware_extensions(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_known_ext_group_below_threshold_skips() {
let records = vec![
make_record(100, "a.locked", UsnReason::RENAME_NEW_NAME, ts(0), 1000),
make_record(101, "b.locked", UsnReason::RENAME_NEW_NAME, ts(1), 1100),
];
let indicators = detect_known_ransomware_extensions(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_mass_rename_group_below_threshold_skips() {
let mut records = Vec::new();
for i in 0..25u64 {
let ext = if i % 2 == 0 { "aaa" } else { "bbb" };
records.push(make_record(
100 + i,
&format!("file{i}.{ext}"),
UsnReason::RENAME_NEW_NAME,
ts(i as i64),
1000 + (i as i64) * 100,
));
}
let indicators = detect_mass_rename_patterns(&records);
assert!(indicators.is_empty());
}
#[test]
fn test_timestomp_with_close_falls_through_when_empty() {
let records = vec![make_record(
100,
"plain.bin",
UsnReason::DATA_OVERWRITE,
ts(1000),
5000,
)];
let indicators = detect_timestomping(&records);
assert!(indicators.is_empty());
}
}