1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
8
9pub mod analysis;
10pub mod correlation;
11pub mod rules;
12pub mod triage;
13
14use forensicnomicon::ntfs::{attr_types, SIGNATURE_BAAD, SIGNATURE_FILE};
15
16use ntfs_core::attribute::Attribute;
17use ntfs_core::file_name::FileName;
18use ntfs_core::record::MftRecordHeader;
19use ntfs_core::standard_information::StandardInformation;
20use ntfs_core::time::Filetime;
21
22const TICKS_PER_SECOND: u64 = 10_000_000;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct TimestompIndicators {
31 pub si_created_before_fn: bool,
33 pub created_mismatch: bool,
35 pub si_whole_second: bool,
38}
39
40impl TimestompIndicators {
41 #[must_use]
43 pub fn is_suspicious(&self) -> bool {
44 self.si_created_before_fn || self.si_whole_second
45 }
46}
47
48#[must_use]
51pub fn detect_timestomp(si: &StandardInformation, file_name: &FileName) -> TimestompIndicators {
52 TimestompIndicators {
53 si_created_before_fn: si.created.0 < file_name.created.0,
54 created_mismatch: si.created.0 != file_name.created.0,
55 si_whole_second: whole_second(si.created)
56 || whole_second(si.modified)
57 || whole_second(si.mft_modified)
58 || whole_second(si.accessed),
59 }
60}
61
62fn whole_second(ft: Filetime) -> bool {
64 ft.0 != 0 && ft.0 % TICKS_PER_SECOND == 0
65}
66
67#[must_use]
69pub fn alternate_data_streams(attributes: &[Attribute]) -> Vec<&Attribute> {
70 attributes
71 .iter()
72 .filter(|a| a.type_code == attr_types::DATA && a.name.is_some())
73 .collect()
74}
75
76#[must_use]
79pub fn record_slack<'a>(record: &'a [u8], header: &MftRecordHeader) -> &'a [u8] {
80 let used = header.used_size as usize;
81 record.get(used..).unwrap_or(&[])
82}
83
84#[must_use]
86pub fn is_deleted(header: &MftRecordHeader) -> bool {
87 !header.is_in_use()
88}
89
90#[must_use]
93pub fn carve_file_records(mft: &[u8], record_size: usize) -> Vec<usize> {
94 if record_size == 0 {
95 return Vec::new();
96 }
97 let mut offsets = Vec::new();
98 let mut pos = 0;
99 while pos + 4 <= mft.len() {
100 let sig = &mft[pos..pos + 4];
101 if sig == SIGNATURE_FILE || sig == SIGNATURE_BAAD {
102 offsets.push(pos);
103 }
104 pos += record_size;
105 }
106 offsets
107}
108
109pub use forensicnomicon::report::Severity;
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AnomalyKind {
125 Timestomp {
128 record: u64,
130 signal: &'static str,
132 },
133 AlternateDataStream {
136 record: u64,
138 stream: String,
140 },
141 DeletedRecord {
143 record: u64,
145 },
146 RecordSlackResidue {
148 record: u64,
150 residue_len: usize,
152 },
153}
154
155impl AnomalyKind {
156 #[must_use]
158 pub fn record(&self) -> u64 {
159 match self {
160 AnomalyKind::Timestomp { record, .. }
161 | AnomalyKind::AlternateDataStream { record, .. }
162 | AnomalyKind::DeletedRecord { record }
163 | AnomalyKind::RecordSlackResidue { record, .. } => *record,
164 }
165 }
166
167 #[must_use]
169 pub fn severity(&self) -> Severity {
170 match self {
171 AnomalyKind::Timestomp { .. } => Severity::High,
172 AnomalyKind::AlternateDataStream { .. } | AnomalyKind::RecordSlackResidue { .. } => {
173 Severity::Low
174 }
175 AnomalyKind::DeletedRecord { .. } => Severity::Info,
176 }
177 }
178
179 #[must_use]
181 pub fn code(&self) -> &'static str {
182 match self {
183 AnomalyKind::Timestomp { .. } => "NTFS-TIMESTOMP",
184 AnomalyKind::AlternateDataStream { .. } => "NTFS-ADS",
185 AnomalyKind::DeletedRecord { .. } => "NTFS-DELETED-RECORD",
186 AnomalyKind::RecordSlackResidue { .. } => "NTFS-SLACK-RESIDUE",
187 }
188 }
189
190 #[must_use]
192 pub fn note(&self) -> String {
193 match self {
194 AnomalyKind::Timestomp { record, signal } => format!(
195 "record {record}: $STANDARD_INFORMATION timestamps consistent with tampering ({signal})"
196 ),
197 AnomalyKind::AlternateDataStream { record, stream } => format!(
198 "record {record}: named $DATA stream `{stream}` — consistent with data carried in an alternate data stream"
199 ),
200 AnomalyKind::DeletedRecord { record } => {
201 format!("record {record}: MFT entry not in use — a recoverable deleted file")
202 }
203 AnomalyKind::RecordSlackResidue { record, residue_len } => format!(
204 "record {record}: {residue_len} non-zero byte(s) in MFT-record slack — consistent with residue from an overwritten resident attribute"
205 ),
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct Anomaly {
214 pub severity: Severity,
216 pub code: &'static str,
218 pub kind: AnomalyKind,
220 pub note: String,
222}
223
224impl Anomaly {
225 #[must_use]
227 pub fn new(kind: AnomalyKind) -> Self {
228 Anomaly {
229 severity: kind.severity(),
230 code: kind.code(),
231 note: kind.note(),
232 kind,
233 }
234 }
235}
236
237impl forensicnomicon::report::Observation for Anomaly {
238 fn severity(&self) -> Option<Severity> {
239 Some(self.severity)
240 }
241 fn code(&self) -> &'static str {
242 self.code
243 }
244 fn note(&self) -> String {
245 self.note.clone()
246 }
247 fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
248 let record = self.kind.record();
249 vec![forensicnomicon::report::Evidence {
250 field: "mft record".to_string(),
251 value: record.to_string(),
252 location: Some(forensicnomicon::report::Location::RecordId(record)),
253 }]
254 }
255}
256
257#[must_use]
261pub fn audit_components(
262 record_number: u64,
263 header: &MftRecordHeader,
264 record: &[u8],
265 attributes: &[Attribute],
266 standard_information: Option<&StandardInformation>,
267 primary_file_name: Option<&FileName>,
268) -> Vec<Anomaly> {
269 let mut out = Vec::new();
270
271 if is_deleted(header) {
272 out.push(Anomaly::new(AnomalyKind::DeletedRecord {
273 record: record_number,
274 }));
275 }
276
277 let residue = record_slack(record, header)
278 .iter()
279 .filter(|&&b| b != 0)
280 .count();
281 if residue > 0 {
282 out.push(Anomaly::new(AnomalyKind::RecordSlackResidue {
283 record: record_number,
284 residue_len: residue,
285 }));
286 }
287
288 for ads in alternate_data_streams(attributes) {
289 out.push(Anomaly::new(AnomalyKind::AlternateDataStream {
290 record: record_number,
291 stream: ads.name.clone().unwrap_or_default(),
292 }));
293 }
294
295 if let (Some(si), Some(fname)) = (standard_information, primary_file_name) {
296 let ind = detect_timestomp(si, fname);
297 if ind.si_created_before_fn {
298 out.push(Anomaly::new(AnomalyKind::Timestomp {
299 record: record_number,
300 signal: "$SI created before $FN",
301 }));
302 }
303 if ind.si_whole_second {
304 out.push(Anomaly::new(AnomalyKind::Timestomp {
305 record: record_number,
306 signal: "$SI timestamp on a whole second",
307 }));
308 }
309 }
310
311 out
312}
313
314#[must_use]
319pub fn audit_record(record: &[u8]) -> Vec<Anomaly> {
320 let Ok(header) = MftRecordHeader::parse(record) else {
321 return Vec::new();
322 };
323 let attributes =
324 ntfs_core::attribute::parse_attributes(record, header.first_attribute_offset as usize)
325 .unwrap_or_default();
326
327 let resident = |type_code: u32| {
328 attributes
329 .iter()
330 .find(|a| a.type_code == type_code)
331 .and_then(|a| a.resident_content(record))
332 };
333 let si =
334 resident(attr_types::STANDARD_INFORMATION).and_then(|c| StandardInformation::parse(c).ok());
335 let fname = resident(attr_types::FILE_NAME).and_then(|c| FileName::parse(c).ok());
336
337 audit_components(
338 u64::from(header.record_number),
339 &header,
340 record,
341 &attributes,
342 si.as_ref(),
343 fname.as_ref(),
344 )
345}
346
347const MIRROR_NAMES: [&str; 4] = ["$MFT", "$MFTMirr", "$LogFile", "$Volume"];
355
356fn mismatched_names(entries: &[usize]) -> String {
358 entries
359 .iter()
360 .map(|&i| MIRROR_NAMES.get(i).copied().unwrap_or("?"))
361 .collect::<Vec<_>>()
362 .join(", ")
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum ArtifactAnomaly {
369 MftMirrorMismatch {
372 mismatched_entries: Vec<usize>,
374 },
375 LogFileCleared,
378}
379
380impl ArtifactAnomaly {
381 #[must_use]
383 pub fn severity(&self) -> Severity {
384 match self {
385 ArtifactAnomaly::MftMirrorMismatch { .. } => Severity::High,
386 ArtifactAnomaly::LogFileCleared => Severity::Medium,
387 }
388 }
389
390 #[must_use]
392 pub fn code(&self) -> &'static str {
393 match self {
394 ArtifactAnomaly::MftMirrorMismatch { .. } => "NTFS-MFTMIRR-MISMATCH",
395 ArtifactAnomaly::LogFileCleared => "NTFS-LOGFILE-CLEARED",
396 }
397 }
398
399 #[must_use]
401 pub fn note(&self) -> String {
402 match self {
403 ArtifactAnomaly::MftMirrorMismatch { mismatched_entries } => format!(
404 "$MFTMirr differs from $MFT for {} — consistent with MFT tampering or corruption",
405 mismatched_names(mismatched_entries)
406 ),
407 ArtifactAnomaly::LogFileCleared => "$LogFile shows gaps/restart-area anomalies — consistent with the transaction journal having been cleared".to_string(),
408 }
409 }
410}
411
412impl forensicnomicon::report::Observation for ArtifactAnomaly {
413 fn severity(&self) -> Option<Severity> {
414 Some(self.severity())
415 }
416 fn code(&self) -> &'static str {
417 self.code()
418 }
419 fn note(&self) -> String {
420 self.note()
421 }
422 fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
423 use forensicnomicon::report::{Evidence, Location};
424 match self {
425 ArtifactAnomaly::MftMirrorMismatch { mismatched_entries } => vec![Evidence {
426 field: "mismatched system records".to_string(),
427 value: mismatched_names(mismatched_entries),
428 location: Some(Location::Field("$MFTMirr".to_string())),
429 }],
430 ArtifactAnomaly::LogFileCleared => vec![Evidence {
431 field: "$LogFile".to_string(),
432 value: "gaps/restart-area anomalies consistent with journal clearing".to_string(),
433 location: Some(Location::Field("$LogFile".to_string())),
434 }],
435 }
436 }
437}
438
439#[must_use]
442pub fn audit_mft_mirror(mft_data: &[u8], mftmirr_data: &[u8]) -> Vec<ArtifactAnomaly> {
443 match ntfs_core::mftmirr::compare_mft_mirror(mft_data, mftmirr_data) {
444 Ok(cmp) if !cmp.is_consistent => {
445 let mismatched_entries = cmp
446 .matches
447 .iter()
448 .enumerate()
449 .filter_map(|(i, &m)| (!m).then_some(i))
450 .collect();
451 vec![ArtifactAnomaly::MftMirrorMismatch { mismatched_entries }]
452 }
453 _ => Vec::new(),
454 }
455}
456
457#[must_use]
460pub fn audit_logfile(logfile_data: &[u8]) -> Vec<ArtifactAnomaly> {
461 match ntfs_core::logfile::parse_logfile(logfile_data) {
462 Ok(summary) if ntfs_core::logfile::detect_journal_clearing(&summary) => {
463 vec![ArtifactAnomaly::LogFileCleared]
464 }
465 _ => Vec::new(),
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use ntfs_core::attribute::AttributeBody;
473
474 fn si(created: u64, modified: u64, mft_modified: u64, accessed: u64) -> StandardInformation {
475 StandardInformation {
476 created: Filetime(created),
477 modified: Filetime(modified),
478 mft_modified: Filetime(mft_modified),
479 accessed: Filetime(accessed),
480 file_attributes: 0,
481 security_id: None,
482 usn: None,
483 }
484 }
485
486 fn fname(created: u64) -> FileName {
487 use ntfs_core::file_name::FileReference;
488 FileName {
489 parent: FileReference::from_u64(5),
490 created: Filetime(created),
491 modified: Filetime(created),
492 mft_modified: Filetime(created),
493 accessed: Filetime(created),
494 allocated_size: 0,
495 real_size: 0,
496 flags: 0,
497 namespace: 1,
498 name: "f".to_string(),
499 }
500 }
501
502 fn data_attr(name: Option<&str>) -> Attribute {
503 Attribute {
504 type_code: attr_types::DATA,
505 length: 0,
506 non_resident: false,
507 name: name.map(str::to_string),
508 flags: 0,
509 attribute_id: 0,
510 offset: 0,
511 body: AttributeBody::Resident {
512 content_offset: 0,
513 content_length: 0,
514 },
515 }
516 }
517
518 #[test]
519 fn timestomp_si_before_fn_is_suspicious() {
520 let ind = detect_timestomp(&si(1_000, 1_000, 1_000, 1_000), &fname(2_000_000_000));
522 assert!(ind.si_created_before_fn);
523 assert!(ind.is_suspicious());
524 }
525
526 #[test]
527 fn timestomp_whole_second_is_suspicious() {
528 let t = 5 * TICKS_PER_SECOND;
530 let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
531 assert!(ind.si_whole_second);
532 assert!(ind.is_suspicious());
533 }
534
535 #[test]
536 fn matching_subsecond_times_are_clean() {
537 let t = 129_067_776_000_000_123; let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
539 assert!(!ind.is_suspicious());
540 assert!(!ind.created_mismatch);
541 }
542
543 #[test]
544 fn finds_alternate_data_streams() {
545 let attrs = [
546 data_attr(None),
547 data_attr(Some("Zone.Identifier")),
548 data_attr(Some("evil")),
549 ];
550 let ads = alternate_data_streams(&attrs);
551 assert_eq!(ads.len(), 2);
552 assert_eq!(ads[0].name.as_deref(), Some("Zone.Identifier"));
553 }
554
555 #[test]
556 fn slack_is_the_tail_after_used_size() {
557 let mut record = vec![0u8; 1024];
558 record[600..610].copy_from_slice(b"RESIDUEXYZ");
559 let header = MftRecordHeader {
560 signature: *b"FILE",
561 usa_offset: 0x30,
562 usa_count: 3,
563 lsn: 0,
564 sequence_number: 1,
565 hard_link_count: 1,
566 first_attribute_offset: 0x38,
567 flags: 0x01,
568 used_size: 600,
569 allocated_size: 1024,
570 base_record: 0,
571 next_attr_id: 1,
572 record_number: 0,
573 };
574 let slack = record_slack(&record, &header);
575 assert_eq!(slack.len(), 1024 - 600);
576 assert_eq!(&slack[0..10], b"RESIDUEXYZ");
577 }
578
579 #[test]
580 fn deleted_when_not_in_use() {
581 let mut header = MftRecordHeader {
582 signature: *b"FILE",
583 usa_offset: 0x30,
584 usa_count: 3,
585 lsn: 0,
586 sequence_number: 1,
587 hard_link_count: 1,
588 first_attribute_offset: 0x38,
589 flags: 0x00, used_size: 0x100,
591 allocated_size: 1024,
592 base_record: 0,
593 next_attr_id: 1,
594 record_number: 0,
595 };
596 assert!(is_deleted(&header));
597 header.flags = 0x01;
598 assert!(!is_deleted(&header));
599 }
600
601 #[test]
602 fn carve_with_zero_record_size_is_empty() {
603 assert!(carve_file_records(b"FILE....", 0).is_empty());
605 }
606
607 #[test]
608 fn carves_file_records_at_boundaries() {
609 let rec = 1024usize;
610 let mut mft = vec![0u8; rec * 4];
611 mft[0..4].copy_from_slice(b"FILE"); mft[2 * rec..2 * rec + 4].copy_from_slice(b"BAAD"); let offsets = carve_file_records(&mft, rec);
615 assert_eq!(offsets, vec![0, 2 * rec]);
616 }
617
618 fn hdr(flags: u16, used_size: u32, record_number: u32) -> MftRecordHeader {
621 MftRecordHeader {
622 signature: *b"FILE",
623 usa_offset: 0x30,
624 usa_count: 3,
625 lsn: 0,
626 sequence_number: 1,
627 hard_link_count: 1,
628 first_attribute_offset: 0x38,
629 flags,
630 used_size,
631 allocated_size: 1024,
632 base_record: 0,
633 next_attr_id: 1,
634 record_number,
635 }
636 }
637
638 #[test]
639 fn audit_flags_deleted_record() {
640 let header = hdr(0x00, 0x100, 42); let an = audit_components(42, &header, &vec![0u8; 1024], &[], None, None);
642 assert!(an
643 .iter()
644 .any(|a| matches!(a.kind, AnomalyKind::DeletedRecord { record: 42 })));
645 }
646
647 #[test]
648 fn audit_flags_timestomp() {
649 let header = hdr(0x01, 0x100, 7);
650 let si = si(1_000, 1_000, 1_000, 1_000);
651 let fnm = fname(2_000_000_000);
652 let an = audit_components(7, &header, &vec![0u8; 1024], &[], Some(&si), Some(&fnm));
653 assert!(an
654 .iter()
655 .any(|a| matches!(a.kind, AnomalyKind::Timestomp { .. })));
656 }
657
658 #[test]
659 fn audit_flags_alternate_data_stream() {
660 let header = hdr(0x01, 0x100, 9);
661 let attrs = [data_attr(None), data_attr(Some("evil"))];
662 let an = audit_components(9, &header, &vec![0u8; 1024], &attrs, None, None);
663 assert!(an.iter().any(
664 |a| matches!(&a.kind, AnomalyKind::AlternateDataStream { stream, .. } if stream == "evil")
665 ));
666 }
667
668 #[test]
669 fn audit_flags_slack_residue() {
670 let header = hdr(0x01, 600, 3);
671 let mut record = vec![0u8; 1024];
672 record[700] = 0xAA;
673 let an = audit_components(3, &header, &record, &[], None, None);
674 assert!(an
675 .iter()
676 .any(|a| matches!(a.kind, AnomalyKind::RecordSlackResidue { .. })));
677 }
678
679 #[test]
680 fn audit_clean_record_has_no_anomalies() {
681 let header = hdr(0x01, 1024, 1); let an = audit_components(1, &header, &vec![0u8; 1024], &[], None, None);
683 assert!(an.is_empty(), "clean record: {an:?}");
684 }
685
686 #[test]
687 fn audit_record_on_non_record_bytes_is_empty_not_panic() {
688 assert!(audit_record(&[0u8; 16]).is_empty());
690 assert!(audit_record(b"not even a FILE record").is_empty());
691 }
692
693 fn resident_attr(type_code: u32, content: &[u8]) -> Vec<u8> {
697 let content_offset = 0x18usize;
698 let length = (content_offset + content.len() + 7) & !7;
699 let mut a = vec![0u8; length];
700 a[0..4].copy_from_slice(&type_code.to_le_bytes()); a[4..8].copy_from_slice(&(length as u32).to_le_bytes()); a[8] = 0; a[0x0A..0x0C].copy_from_slice(&(content_offset as u16).to_le_bytes()); a[0x0E..0x10].copy_from_slice(&1u16.to_le_bytes()); a[0x10..0x14].copy_from_slice(&(content.len() as u32).to_le_bytes()); a[0x14..0x16].copy_from_slice(&(content_offset as u16).to_le_bytes()); a[content_offset..content_offset + content.len()].copy_from_slice(content);
708 a
709 }
710
711 fn si_content(created: u64) -> Vec<u8> {
712 let mut c = vec![0u8; 0x30];
713 c[0x00..0x08].copy_from_slice(&created.to_le_bytes()); c
715 }
716
717 fn fn_content(created: u64) -> Vec<u8> {
718 let mut c = vec![0u8; 0x44]; c[0x00..0x08].copy_from_slice(&5u64.to_le_bytes()); c[0x08..0x10].copy_from_slice(&created.to_le_bytes()); c[0x40] = 1; c[0x41] = 1; c[0x42..0x44].copy_from_slice(&u16::from(b'f').to_le_bytes());
724 c
725 }
726
727 #[test]
728 fn audit_record_parses_and_flags_timestomp_end_to_end() {
729 let si = resident_attr(attr_types::STANDARD_INFORMATION, &si_content(1_000));
733 let fnm = resident_attr(attr_types::FILE_NAME, &fn_content(2_000_000_000));
734 let first_attr = 0x30usize;
735
736 let mut rec = vec![0u8; 1024];
737 rec[0..4].copy_from_slice(b"FILE");
738 rec[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes()); rec[0x06..0x08].copy_from_slice(&3u16.to_le_bytes()); rec[0x14..0x16].copy_from_slice(&(first_attr as u16).to_le_bytes()); rec[0x16..0x18].copy_from_slice(&0x01u16.to_le_bytes()); rec[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes()); rec[0x2C..0x30].copy_from_slice(&7u32.to_le_bytes()); let mut pos = first_attr;
746 rec[pos..pos + si.len()].copy_from_slice(&si);
747 pos += si.len();
748 rec[pos..pos + fnm.len()].copy_from_slice(&fnm);
749 pos += fnm.len();
750 rec[pos..pos + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); rec[0x18..0x1C].copy_from_slice(&((pos + 4) as u32).to_le_bytes()); let anomalies = audit_record(&rec);
754 let timestomped = anomalies
755 .iter()
756 .any(|a| matches!(a.kind, AnomalyKind::Timestomp { record: 7, .. }));
757 assert!(timestomped, "{anomalies:?}");
758 }
759
760 #[test]
761 fn audit_components_flags_whole_second_timestomp() {
762 let header = hdr(0x01, 1024, 4);
765 let whole = 5 * 10_000_000; let si = si(whole, whole, whole, whole);
767 let fnm = fname(whole);
768 let an = audit_components(4, &header, &vec![0u8; 1024], &[], Some(&si), Some(&fnm));
769 assert!(an
770 .iter()
771 .any(|a| matches!(&a.kind, AnomalyKind::Timestomp { signal, .. } if signal.contains("whole second"))));
772 }
773
774 #[test]
775 fn every_anomaly_kind_carries_its_record_as_evidence() {
776 use forensicnomicon::report::{Location, Observation};
777 let kinds = [
779 AnomalyKind::Timestomp {
780 record: 1,
781 signal: "x",
782 },
783 AnomalyKind::AlternateDataStream {
784 record: 2,
785 stream: "s".to_string(),
786 },
787 AnomalyKind::DeletedRecord { record: 3 },
788 AnomalyKind::RecordSlackResidue {
789 record: 4,
790 residue_len: 5,
791 },
792 ];
793 for (i, k) in kinds.into_iter().enumerate() {
794 let ev = Anomaly::new(k).evidence();
795 let rec = (i + 1) as u64;
796 assert!(ev
797 .iter()
798 .any(|e| matches!(e.location, Some(Location::RecordId(r)) if r == rec)));
799 }
800 }
801
802 #[test]
803 fn anomaly_converts_to_canonical_finding() {
804 use forensicnomicon::report::{Observation, Source};
805 let a = Anomaly::new(AnomalyKind::Timestomp {
806 record: 5,
807 signal: "test",
808 });
809 let f = a.to_finding(Source {
810 analyzer: "ntfs-forensic".to_string(),
811 scope: "NTFS".to_string(),
812 version: None,
813 });
814 assert!(f.code.starts_with("NTFS-"));
815 assert!(f.severity.is_some());
816 }
817
818 fn rstr_page() -> Vec<u8> {
821 let mut p = vec![0u8; 0x1000];
822 p[0..4].copy_from_slice(b"RSTR");
823 p[0x10..0x12].copy_from_slice(&1u16.to_le_bytes());
824 p[0x20..0x24].copy_from_slice(&4096u32.to_le_bytes());
825 p[0x24..0x28].copy_from_slice(&4096u32.to_le_bytes());
826 p
827 }
828
829 fn rcrd_page(lsn: u64) -> Vec<u8> {
830 let mut p = vec![0u8; 0x1000];
831 p[0..4].copy_from_slice(b"RCRD");
832 p[0x18..0x20].copy_from_slice(&lsn.to_le_bytes());
833 p
834 }
835
836 #[test]
837 fn audit_mft_mirror_consistent_yields_no_findings() {
838 let mft = vec![0xAAu8; 1024 * 4];
839 assert!(audit_mft_mirror(&mft, &mft).is_empty());
840 }
841
842 #[test]
843 fn audit_mft_mirror_flags_each_differing_system_record() {
844 let mft = vec![0xAAu8; 1024 * 4];
845 let mut mirr = mft.clone();
846 mirr[0] = 0xBB; mirr[1024 * 2] = 0xCC; let anomalies = audit_mft_mirror(&mft, &mirr);
849 assert_eq!(anomalies.len(), 1);
850 assert_eq!(
851 anomalies[0],
852 ArtifactAnomaly::MftMirrorMismatch {
853 mismatched_entries: vec![0, 2]
854 }
855 );
856 assert_eq!(anomalies[0].severity(), Severity::High);
857 assert_eq!(anomalies[0].code(), "NTFS-MFTMIRR-MISMATCH");
858 let note = anomalies[0].note();
859 assert!(note.contains("$MFT") && note.contains("$LogFile"));
860 }
861
862 #[test]
863 fn audit_logfile_flags_cleared_journal() {
864 let anomalies = audit_logfile(&[]);
866 assert_eq!(anomalies.len(), 1);
867 assert_eq!(anomalies[0], ArtifactAnomaly::LogFileCleared);
868 assert_eq!(anomalies[0].severity(), Severity::Medium);
869 assert_eq!(anomalies[0].code(), "NTFS-LOGFILE-CLEARED");
870 assert!(anomalies[0].note().contains("$LogFile"));
871 }
872
873 #[test]
874 fn audit_logfile_normal_journal_yields_no_findings() {
875 let mut data = Vec::new();
877 data.extend_from_slice(&rstr_page());
878 data.extend_from_slice(&rstr_page());
879 data.extend_from_slice(&rcrd_page(3000));
880 assert!(audit_logfile(&data).is_empty());
881 }
882
883 #[test]
884 fn artifact_anomalies_convert_to_canonical_findings() {
885 use forensicnomicon::report::{Evidence, Observation, Source};
886 let src = || Source {
887 analyzer: "ntfs-forensic".to_string(),
888 scope: "volume".to_string(),
889 version: None,
890 };
891
892 let mirror = ArtifactAnomaly::MftMirrorMismatch {
893 mismatched_entries: vec![1],
894 };
895 let f = mirror.to_finding(src());
896 assert_eq!(f.code, "NTFS-MFTMIRR-MISMATCH");
897 assert_eq!(f.severity, Some(Severity::High));
898 let ev: &Evidence = &f.evidence[0];
899 assert_eq!(ev.value, "$MFTMirr");
900
901 let cleared = ArtifactAnomaly::LogFileCleared;
902 let f = cleared.to_finding(src());
903 assert_eq!(f.code, "NTFS-LOGFILE-CLEARED");
904 assert!(!f.evidence.is_empty());
905 }
906
907 #[test]
908 fn mismatched_names_handles_out_of_range_index() {
909 assert_eq!(mismatched_names(&[99]), "?");
911 }
912}