use regex::Regex;
use ntfs_core::rewind::ResolvedRecord;
use ntfs_core::usn::UsnReason;
pub mod queries;
#[derive(Debug, Clone)]
pub struct TriageQuestion {
pub id: &'static str,
pub category: &'static str,
pub question: &'static str,
pub query: TriageQuery,
}
#[derive(Debug, Clone, Default)]
pub struct TriageQuery {
pub path_patterns: Vec<&'static str>,
pub extension_filter: Vec<&'static str>,
pub reasons: Option<UsnReason>,
pub exclude_patterns: Vec<&'static str>,
pub filename_filter: Vec<&'static str>,
pub source_filter: Vec<&'static str>,
}
pub struct TriageResult {
pub id: &'static str,
pub category: &'static str,
pub question: &'static str,
pub has_hits: bool,
pub hit_count: usize,
pub record_indices: Vec<usize>,
}
#[must_use]
pub fn run_triage(questions: &[TriageQuestion], records: &[ResolvedRecord]) -> Vec<TriageResult> {
questions
.iter()
.map(|q| {
let record_indices: Vec<usize> = records
.iter()
.enumerate()
.filter(|(_, r)| matches_query(&q.query, r))
.map(|(i, _)| i)
.collect();
let hit_count = record_indices.len();
TriageResult {
id: q.id,
category: q.category,
question: q.question,
has_hits: hit_count > 0,
hit_count,
record_indices,
}
})
.collect()
}
fn matches_query(query: &TriageQuery, record: &ResolvedRecord) -> bool {
if query.path_patterns.is_empty()
&& query.extension_filter.is_empty()
&& query.reasons.is_none()
&& query.exclude_patterns.is_empty()
&& query.filename_filter.is_empty()
&& query.source_filter.is_empty()
{
return false;
}
if !query.source_filter.is_empty() {
let source_str = record.source.as_str();
if !query.source_filter.contains(&source_str) {
return false;
}
}
if let Some(reasons) = query.reasons {
if !record.record.reason.intersects(reasons) {
return false;
}
}
if !query.path_patterns.is_empty() {
let path_lower = record.full_path.to_lowercase();
let any_match = query
.path_patterns
.iter()
.any(|pat| Regex::new(&pat.to_lowercase()).is_ok_and(|re| re.is_match(&path_lower)));
if !any_match {
return false;
}
}
if !query.extension_filter.is_empty() {
let name_lower = record.record.filename.to_lowercase();
let any_ext = query.extension_filter.iter().any(|ext| {
let dot_ext = format!(".{}", ext.to_lowercase());
name_lower.ends_with(&dot_ext)
});
if !any_ext {
return false;
}
}
if !query.filename_filter.is_empty() {
let name_lower = record.record.filename.to_lowercase();
let any_name = query
.filename_filter
.iter()
.any(|kw| name_lower.contains(&kw.to_lowercase()));
if !any_name {
return false;
}
}
if !query.exclude_patterns.is_empty() {
let path_lower = record.full_path.to_lowercase();
let any_exclude = query
.exclude_patterns
.iter()
.any(|pat| Regex::new(&pat.to_lowercase()).is_ok_and(|re| re.is_match(&path_lower)));
if any_exclude {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::DateTime;
use ntfs_core::usn::{FileAttributes, UsnRecord};
fn make_resolved(full_path: &str, filename: &str, reason: UsnReason) -> ResolvedRecord {
ResolvedRecord {
record: UsnRecord {
mft_entry: 100,
mft_sequence: 1,
parent_mft_entry: 5,
parent_mft_sequence: 5,
usn: 1000,
timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
reason,
filename: filename.to_string(),
file_attributes: FileAttributes::ARCHIVE,
source_info: 0,
security_id: 0,
major_version: 2,
},
full_path: full_path.to_string(),
parent_path: ".".to_string(),
source: ntfs_core::rewind::RecordSource::Allocated,
}
}
#[test]
fn test_malware_query_matches_exe_in_system32() {
let records = vec![make_resolved(
r".\Windows\System32\evil.exe",
"evil.exe",
UsnReason::FILE_CREATE,
)];
let questions = vec![TriageQuestion {
id: "malware_deployed",
category: "Breach & Malware",
question: "Were executables dropped in suspicious locations?",
query: TriageQuery {
path_patterns: vec![r"System32", r"SysWOW64", r"Temp", r"AppData"],
extension_filter: vec!["exe", "dll"],
reasons: Some(UsnReason::FILE_CREATE),
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results.len(), 1);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_query_excludes_by_pattern() {
let records = vec![
make_resolved(
r".\Windows\System32\legit.dll",
"legit.dll",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Users\admin\AppData\Local\Temp\dropper.exe",
"dropper.exe",
UsnReason::FILE_CREATE,
),
];
let questions = vec![TriageQuestion {
id: "test_exclude",
category: "Test",
question: "Test exclusion",
query: TriageQuery {
path_patterns: vec![r"System32", r"Temp", r"AppData"],
extension_filter: vec!["exe", "dll"],
reasons: Some(UsnReason::FILE_CREATE),
exclude_patterns: vec![r"Windows"],
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results.len(), 1);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![1]);
}
#[test]
fn test_query_no_hits_returns_empty() {
let records = vec![make_resolved(
r".\Documents\report.pdf",
"report.pdf",
UsnReason::DATA_EXTEND,
)];
let questions = vec![TriageQuestion {
id: "no_hits",
category: "Test",
question: "Should find nothing",
query: TriageQuery {
path_patterns: vec![r"System32"],
extension_filter: vec!["exe"],
reasons: Some(UsnReason::FILE_CREATE),
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results.len(), 1);
assert!(!results[0].has_hits);
assert_eq!(results[0].hit_count, 0);
assert!(results[0].record_indices.is_empty());
}
#[test]
fn test_filename_filter_matches() {
let records = vec![
make_resolved(
r".\Windows\System32\mimikatz.exe",
"mimikatz.exe",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Users\admin\Desktop\notes.txt",
"notes.txt",
UsnReason::DATA_EXTEND,
),
];
let questions = vec![TriageQuestion {
id: "cred_access",
category: "Credential Access",
question: "Were credential tools used?",
query: TriageQuery {
filename_filter: vec!["mimikatz", "procdump", "lsass"],
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results.len(), 1);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
fn make_resolved_with_source(
full_path: &str,
filename: &str,
reason: UsnReason,
source: ntfs_core::rewind::RecordSource,
) -> ResolvedRecord {
let mut r = make_resolved(full_path, filename, reason);
r.source = source;
r
}
#[test]
fn test_source_filter_matches_carved_only() {
use ntfs_core::rewind::RecordSource;
let records = vec![
make_resolved_with_source(
r".\Users\admin\secret.docx",
"secret.docx",
UsnReason::FILE_CREATE,
RecordSource::Allocated,
),
make_resolved_with_source(
r".\Users\admin\deleted.exe",
"deleted.exe",
UsnReason::FILE_CREATE,
RecordSource::Carved,
),
make_resolved_with_source(
r".\Users\admin\ghost.dll",
"ghost.dll",
UsnReason::FILE_CREATE,
RecordSource::Ghost,
),
];
let questions = vec![TriageQuestion {
id: "carved_only",
category: "Test",
question: "Only carved records?",
query: TriageQuery {
source_filter: vec!["entry-carved"],
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![1]);
}
#[test]
fn test_source_filter_matches_carved_and_ghost() {
use ntfs_core::rewind::RecordSource;
let records = vec![
make_resolved_with_source(
r".\allocated.txt",
"allocated.txt",
UsnReason::FILE_CREATE,
RecordSource::Allocated,
),
make_resolved_with_source(
r".\carved.exe",
"carved.exe",
UsnReason::FILE_CREATE,
RecordSource::Carved,
),
make_resolved_with_source(
r".\ghost.dll",
"ghost.dll",
UsnReason::FILE_CREATE,
RecordSource::Ghost,
),
];
let questions = vec![TriageQuestion {
id: "recovered",
category: "Test",
question: "All recovered?",
query: TriageQuery {
source_filter: vec!["entry-carved", "ghost"],
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 2);
assert_eq!(results[0].record_indices, vec![1, 2]);
}
#[test]
fn test_empty_source_filter_matches_all() {
use ntfs_core::rewind::RecordSource;
let records = vec![
make_resolved_with_source(
r".\a.txt",
"a.txt",
UsnReason::FILE_CREATE,
RecordSource::Allocated,
),
make_resolved_with_source(
r".\b.txt",
"b.txt",
UsnReason::FILE_CREATE,
RecordSource::Carved,
),
];
let questions = vec![TriageQuestion {
id: "all",
category: "Test",
question: "All records?",
query: TriageQuery {
reasons: Some(UsnReason::FILE_CREATE),
source_filter: vec![], ..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 2);
}
#[test]
fn test_recovered_evidence_query_uses_source_filter() {
use ntfs_core::rewind::RecordSource;
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "recovered_evidence")
.expect("missing recovered_evidence");
assert!(!q.query.source_filter.is_empty());
let records = vec![
make_resolved_with_source(
r".\normal.txt",
"normal.txt",
UsnReason::FILE_CREATE,
RecordSource::Allocated,
),
make_resolved_with_source(
r".\recovered.exe",
"recovered.exe",
UsnReason::FILE_CREATE,
RecordSource::Carved,
),
make_resolved_with_source(
r".\ghost.dll",
"ghost.dll",
UsnReason::FILE_CREATE,
RecordSource::Ghost,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 2);
}
#[test]
fn test_builtin_questions_returns_12() {
let questions = crate::triage::queries::builtin_questions();
assert_eq!(questions.len(), 12);
}
#[test]
fn test_builtin_has_execution_evidence_question() {
let questions = crate::triage::queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "execution_evidence");
assert!(q.is_some());
}
#[test]
fn test_prefetch_creation_proves_execution() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "execution_evidence")
.expect("missing execution_evidence");
let records = vec![
make_resolved(
r".\Windows\Prefetch\COREUPDATE.EXE-A1B2C3D4.pf",
"COREUPDATE.EXE-A1B2C3D4.pf",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Windows\Prefetch\SVCHOST.EXE-12345678.pf",
"SVCHOST.EXE-12345678.pf",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 2);
}
#[test]
fn test_data_staging_detects_archive_in_user_dir() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "data_staging")
.expect("missing data_staging");
let records = vec![
make_resolved(
r".\Users\admin\Desktop\exfil.zip",
"exfil.zip",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Program Files\7zip\7z.dll",
"7z.dll",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_credential_access_matches_sam_by_path() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "credential_access")
.expect("missing credential_access");
let records = vec![
make_resolved(r".\Windows\System32\config\SAM", "SAM", UsnReason::CLOSE),
make_resolved(
r".\Users\sam\Documents\report.docx",
"report.docx",
UsnReason::CLOSE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
}
#[test]
fn test_evidence_destruction_detects_evtx_deletion() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "evidence_destruction")
.expect("missing evidence_destruction");
let records = vec![
make_resolved(
r".\Windows\System32\winevt\Logs\Security.evtx",
"Security.evtx",
UsnReason::FILE_DELETE,
),
make_resolved(
r".\Windows\Prefetch\MIMIKATZ.EXE-AABBCCDD.pf",
"MIMIKATZ.EXE-AABBCCDD.pf",
UsnReason::FILE_DELETE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 2);
}
#[test]
fn test_file_disguise_detects_ads_operations() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "file_disguise")
.expect("missing file_disguise");
let records = vec![
make_resolved(
r".\Users\admin\document.docx",
"document.docx",
UsnReason::NAMED_DATA_EXTEND,
),
make_resolved(
r".\Users\admin\normal.txt",
"normal.txt",
UsnReason::DATA_EXTEND,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
}
#[test]
fn test_initial_access_detects_exe_in_downloads() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "initial_access")
.expect("missing initial_access");
let records = vec![
make_resolved(
r".\Users\admin\Downloads\payload.exe",
"payload.exe",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Windows\System32\cmd.exe",
"cmd.exe",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
}
#[test]
fn test_malware_deployed_detects_exe_in_programdata() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.expect("missing malware_deployed");
let records = vec![
make_resolved(
r".\ProgramData\evil.exe",
"evil.exe",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Documents\readme.txt",
"readme.txt",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_sensitive_data_detects_xlsx_access() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "sensitive_data")
.expect("missing sensitive_data");
let records = vec![
make_resolved(
r".\Users\admin\Documents\financials.xlsx",
"financials.xlsx",
UsnReason::DATA_EXTEND | UsnReason::CLOSE,
),
make_resolved(
r".\Windows\ProgramData\config.xml",
"config.xml",
UsnReason::DATA_EXTEND,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
}
#[test]
fn test_persistence_detects_exe_in_startup() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "persistence")
.expect("missing persistence");
let records = vec![
make_resolved(
r".\Users\admin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\backdoor.exe",
"backdoor.exe",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Users\admin\Desktop\normal.exe",
"normal.exe",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_lateral_movement_detects_psexec() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "lateral_movement")
.expect("missing lateral_movement");
let records = vec![
make_resolved(
r".\Windows\System32\psexec.exe",
"psexec.exe",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Windows\System32\notepad.exe",
"notepad.exe",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_timestomping_detects_basic_info_change_on_exe() {
let questions = crate::triage::queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "timestomping")
.expect("missing timestomping");
let records = vec![
make_resolved(
r".\Users\admin\Temp\payload.exe",
"payload.exe",
UsnReason::BASIC_INFO_CHANGE,
),
make_resolved(
r".\Windows\WinSxS\something.exe",
"something.exe",
UsnReason::BASIC_INFO_CHANGE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_source_filter_ghost_only() {
use ntfs_core::rewind::RecordSource;
let records = vec![
make_resolved_with_source(
r".\file_a.exe",
"file_a.exe",
UsnReason::FILE_CREATE,
RecordSource::Allocated,
),
make_resolved_with_source(
r".\file_b.exe",
"file_b.exe",
UsnReason::FILE_CREATE,
RecordSource::Carved,
),
make_resolved_with_source(
r".\file_c.exe",
"file_c.exe",
UsnReason::FILE_CREATE,
RecordSource::Ghost,
),
];
let questions = vec![TriageQuestion {
id: "ghost_only",
category: "Test",
question: "Only ghost records?",
query: TriageQuery {
source_filter: vec!["ghost"],
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![2]);
}
#[test]
fn test_record_source_as_str() {
use ntfs_core::rewind::RecordSource;
assert_eq!(RecordSource::Allocated.as_str(), "allocated");
assert_eq!(RecordSource::Carved.as_str(), "entry-carved");
assert_eq!(RecordSource::Ghost.as_str(), "ghost");
}
#[test]
fn test_run_triage_with_empty_records() {
let questions = crate::triage::queries::builtin_questions();
let results = run_triage(&questions, &[]);
assert_eq!(results.len(), 12);
for r in &results {
assert!(!r.has_hits);
assert_eq!(r.hit_count, 0);
assert!(r.record_indices.is_empty());
}
}
#[test]
fn test_run_triage_with_empty_questions() {
let records = vec![make_resolved(
r".\test.exe",
"test.exe",
UsnReason::FILE_CREATE,
)];
let results = run_triage(&[], &records);
assert!(results.is_empty());
}
#[test]
fn test_matches_query_empty_query_matches_nothing() {
let records = vec![make_resolved(
r".\test.exe",
"test.exe",
UsnReason::FILE_CREATE,
)];
let questions = vec![TriageQuestion {
id: "empty",
category: "Test",
question: "Empty query?",
query: TriageQuery::default(),
}];
let results = run_triage(&questions, &records);
assert!(!results[0].has_hits);
assert_eq!(results[0].hit_count, 0);
}
#[test]
fn test_matches_query_reasons_only() {
let records = vec![
make_resolved(r".\anything.txt", "anything.txt", UsnReason::FILE_DELETE),
make_resolved(r".\other.txt", "other.txt", UsnReason::FILE_CREATE),
];
let questions = vec![TriageQuestion {
id: "reasons_only",
category: "Test",
question: "Only reason filter?",
query: TriageQuery {
reasons: Some(UsnReason::FILE_DELETE),
..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 1);
assert_eq!(results[0].record_indices, vec![0]);
}
#[test]
fn test_source_filter_case_sensitivity() {
use ntfs_core::rewind::RecordSource;
let records = vec![make_resolved_with_source(
r".\test.exe",
"test.exe",
UsnReason::FILE_CREATE,
RecordSource::Carved,
)];
let questions = vec![TriageQuestion {
id: "case_test",
category: "Test",
question: "Case sensitive?",
query: TriageQuery {
source_filter: vec!["Carved"], ..Default::default()
},
}];
let results = run_triage(&questions, &records);
assert_eq!(results[0].hit_count, 0);
}
#[test]
fn test_multiple_questions_independent_results() {
let records = vec![
make_resolved(
r".\Windows\Prefetch\CMD.EXE-12345678.pf",
"CMD.EXE-12345678.pf",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Users\admin\Downloads\payload.exe",
"payload.exe",
UsnReason::FILE_CREATE,
),
];
let questions = crate::triage::queries::builtin_questions();
let results = run_triage(&questions, &records);
let exec = results
.iter()
.find(|r| r.id == "execution_evidence")
.unwrap();
assert!(exec.has_hits);
let init = results.iter().find(|r| r.id == "initial_access").unwrap();
assert!(init.has_hits);
let staging = results.iter().find(|r| r.id == "data_staging").unwrap();
assert!(!staging.has_hits);
}
#[test]
fn test_credential_access_excludes_config_systemprofile() {
let records = vec![
make_resolved(
r".\Windows\System32\config\systemprofile\AppData\Local\Microsoft\foo.dat",
"foo.dat",
UsnReason::DATA_TRUNCATION,
),
make_resolved(
r".\Windows\System32\config\SAM",
"SAM",
UsnReason::DATA_OVERWRITE,
),
make_resolved(
r".\Windows\System32\config\SAM.LOG1",
"SAM.LOG1",
UsnReason::DATA_OVERWRITE,
),
make_resolved(
r".\Windows\System32\config\SYSTEM",
"SYSTEM",
UsnReason::DATA_OVERWRITE,
),
make_resolved(
r".\Windows\System32\config\SECURITY",
"SECURITY",
UsnReason::DATA_OVERWRITE,
),
];
let questions = queries::builtin_questions();
let results = run_triage(&questions, &records);
let cred = results
.iter()
.find(|r| r.id == "credential_access")
.unwrap();
assert!(!cred.record_indices.contains(&0));
assert!(cred.record_indices.contains(&1));
assert!(cred.record_indices.contains(&2));
assert!(cred.record_indices.contains(&3));
assert!(cred.record_indices.contains(&4));
}
#[test]
fn test_timestomping_excludes_windowsapps() {
let records = vec![
make_resolved(
r".\Program Files\WindowsApps\Microsoft.Windows.Photos_2020\BendRealityNode.dll",
"BendRealityNode.dll",
UsnReason::BASIC_INFO_CHANGE,
),
make_resolved(
r".\Users\Admin\Downloads\payload.exe",
"payload.exe",
UsnReason::BASIC_INFO_CHANGE,
),
];
let questions = queries::builtin_questions();
let results = run_triage(&questions, &records);
let ts = results.iter().find(|r| r.id == "timestomping").unwrap();
assert!(!ts.record_indices.contains(&0));
assert!(ts.record_indices.contains(&1));
}
#[test]
fn test_sensitive_data_excludes_store_packages() {
let records = vec![
make_resolved(
r".\Users\Admin\AppData\Local\Packages\Microsoft.MicrosoftEdge_8we\temp.txt",
"temp.txt",
UsnReason::FILE_CREATE | UsnReason::CLOSE,
),
make_resolved(
r".\Users\Admin\AppData\Local\Packages\Microsoft.Windows.Search_cw5\cache.txt",
"cache.txt",
UsnReason::FILE_CREATE | UsnReason::CLOSE,
),
make_resolved(
r".\Users\Admin\Documents\passwords.xlsx",
"passwords.xlsx",
UsnReason::DATA_EXTEND | UsnReason::CLOSE,
),
];
let questions = queries::builtin_questions();
let results = run_triage(&questions, &records);
let sd = results.iter().find(|r| r.id == "sensitive_data").unwrap();
assert!(!sd.record_indices.contains(&0));
assert!(!sd.record_indices.contains(&1));
assert!(sd.record_indices.contains(&2));
}
#[test]
fn test_malware_deployed_catches_rename_new_name() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\Temp\dropper.exe",
"dropper.exe",
UsnReason::RENAME_NEW_NAME,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_malware_deployed_catches_security_change() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.unwrap();
let records = vec![make_resolved(
r".\ProgramData\implant.dll",
"implant.dll",
UsnReason::SECURITY_CHANGE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_data_staging_catches_rename_new_name() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "data_staging").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Desktop\exfil.zip",
"exfil.zip",
UsnReason::RENAME_NEW_NAME,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_data_staging_catches_file_delete() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "data_staging").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Downloads\loot.rar",
"loot.rar",
UsnReason::FILE_DELETE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_sensitive_data_catches_file_create() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Documents\secrets.docx",
"secrets.docx",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_sensitive_data_catches_rename_new_name() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Desktop\financials.xlsx",
"financials.xlsx",
UsnReason::RENAME_NEW_NAME,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_sensitive_data_catches_file_delete() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Documents\passwords.kdbx",
"passwords.kdbx",
UsnReason::FILE_DELETE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_sensitive_data_catches_zip_and_lnk() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();
let records = vec![
make_resolved(
r".\Users\admin\Desktop\data.zip",
"data.zip",
UsnReason::FILE_CREATE,
),
make_resolved(
r".\Users\admin\Recent\secret.lnk",
"secret.lnk",
UsnReason::FILE_CREATE,
),
];
let results = run_triage(std::slice::from_ref(q), &records);
assert_eq!(results[0].hit_count, 2);
}
#[test]
fn test_evidence_destruction_catches_data_overwrite() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "evidence_destruction")
.unwrap();
let records = vec![make_resolved(
r".\Windows\System32\winevt\Logs\Security.evtx",
"Security.evtx",
UsnReason::DATA_OVERWRITE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_evidence_destruction_catches_file_create_in_prefetch() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "evidence_destruction")
.unwrap();
let records = vec![make_resolved(
r".\Windows\Prefetch\SDELETE.EXE-AABBCCDD.pf",
"SDELETE.EXE-AABBCCDD.pf",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_initial_access_catches_rename_new_name() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "initial_access").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Downloads\exploit.exe",
"exploit.exe",
UsnReason::RENAME_NEW_NAME,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_malware_deployed_excludes_onedrive() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\Microsoft\OneDrive\21.150.0725.0001\FileSyncShell64.dll",
"FileSyncShell64.dll",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_malware_deployed_excludes_native_images() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\assembly\NativeImages_v4.0.30319_64\System.Core\abc123\System.Core.ni.dll",
"System.Core.ni.dll",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_malware_deployed_excludes_packages() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "malware_deployed")
.unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\Packages\Microsoft.Windows.Photos_8we\app.exe",
"app.exe",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_initial_access_excludes_js_extension() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "initial_access").unwrap();
assert!(!q.query.extension_filter.contains(&"js"));
}
#[test]
fn test_initial_access_excludes_onedrive() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "initial_access").unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\Microsoft\OneDrive\21.150\OneDriveStandaloneUpdater.exe",
"OneDriveStandaloneUpdater.exe",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_initial_access_excludes_packages() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "initial_access").unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\Packages\Microsoft.MicrosoftEdge_8we\AC\script.hta",
"script.hta",
UsnReason::FILE_CREATE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_file_disguise_excludes_assembly() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();
let records = vec![make_resolved(
r".\Windows\assembly\NativeImages_v4.0.30319_64\System.Xml\foo.dll",
"foo.dll",
UsnReason::NAMED_DATA_EXTEND,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_file_disguise_excludes_windowsapps() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();
let records = vec![make_resolved(
r".\Program Files\WindowsApps\Microsoft.Windows.Photos_2020\PhotosApp.dll",
"PhotosApp.dll",
UsnReason::NAMED_DATA_EXTEND,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_file_disguise_excludes_program_files() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();
let records = vec![make_resolved(
r".\Program Files\SomeApp\helper.dll",
"helper.dll",
UsnReason::NAMED_DATA_OVERWRITE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_file_disguise_excludes_software_distribution() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();
let records = vec![make_resolved(
r".\Windows\SoftwareDistribution\Download\abc123\update.exe",
"update.exe",
UsnReason::NAMED_DATA_EXTEND,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_file_disguise_still_catches_user_ads() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();
let records = vec![make_resolved(
r".\Users\admin\Documents\resume.docx",
"resume.docx",
UsnReason::NAMED_DATA_EXTEND,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(results[0].has_hits);
}
#[test]
fn test_persistence_excludes_start_menu_non_startup() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "persistence").unwrap();
let has_start_menu = q.query.path_patterns.contains(&"Start Menu");
assert!(!has_start_menu);
}
#[test]
fn test_timestomping_excludes_windows_temp() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "timestomping").unwrap();
let records = vec![make_resolved(
r".\Windows\Temp\setup_patch.exe",
"setup_patch.exe",
UsnReason::BASIC_INFO_CHANGE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_timestomping_excludes_software_distribution() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "timestomping").unwrap();
let records = vec![make_resolved(
r".\Windows\SoftwareDistribution\Download\abc\update.exe",
"update.exe",
UsnReason::BASIC_INFO_CHANGE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_sensitive_data_excludes_appdata() {
let questions = queries::builtin_questions();
let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();
let records = vec![make_resolved(
r".\Users\admin\AppData\Local\SomeApp\cache.csv",
"cache.csv",
UsnReason::DATA_EXTEND | UsnReason::CLOSE,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_evidence_destruction_excludes_data_truncation() {
let questions = queries::builtin_questions();
let q = questions
.iter()
.find(|q| q.id == "evidence_destruction")
.unwrap();
let records = vec![make_resolved(
r".\Windows\Prefetch\SVCHOST.EXE-12345678.pf",
"SVCHOST.EXE-12345678.pf",
UsnReason::DATA_TRUNCATION,
)];
let results = run_triage(std::slice::from_ref(q), &records);
assert!(!results[0].has_hits);
}
#[test]
fn test_evidence_destruction_excludes_windows_update_logs() {
let records = vec![
make_resolved(
r".\Windows\Logs\WindowsUpdate\WindowsUpdate.20200918.log",
"WindowsUpdate.20200918.log",
UsnReason::FILE_DELETE | UsnReason::CLOSE,
),
make_resolved(
r".\Windows\System32\winevt\Logs\Security.evtx",
"Security.evtx",
UsnReason::FILE_DELETE,
),
];
let questions = queries::builtin_questions();
let results = run_triage(&questions, &records);
let ed = results
.iter()
.find(|r| r.id == "evidence_destruction")
.unwrap();
assert!(!ed.record_indices.contains(&0));
assert!(ed.record_indices.contains(&1));
}
}