#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BodyfileField {
pub index: u8,
pub name: &'static str,
pub description: &'static str,
}
pub const BODYFILE_FIELDS: &[BodyfileField] = &[
BodyfileField { index: 0, name: "MD5", description: "MD5 hash of file content, or 0 if unavailable" },
BodyfileField { index: 1, name: "name", description: "Full file path; deleted files prefixed with (deleted)" },
BodyfileField { index: 2, name: "inode", description: "MFT record number (or inode number on non-NTFS)" },
BodyfileField { index: 3, name: "mode_as_string", description: "File type and permission string (e.g., r/rrwxrwxrwx)" },
BodyfileField { index: 4, name: "UID", description: "User ID (Windows: 0)" },
BodyfileField { index: 5, name: "GID", description: "Group ID (Windows: 0)" },
BodyfileField { index: 6, name: "size", description: "File size in bytes" },
BodyfileField { index: 7, name: "atime", description: "Last accessed (A) — Unix epoch seconds" },
BodyfileField { index: 8, name: "mtime", description: "Last modified (M) — Unix epoch seconds" },
BodyfileField { index: 9, name: "ctime", description: "MFT record changed (C) — Unix epoch seconds" },
BodyfileField { index: 10, name: "crtime", description: "Created / birth (B) — Unix epoch seconds" },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum TimelineOutputFormat {
Bodyfile,
PlasoStore,
L2tCsv,
MacTimeCsv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TimelineTool {
pub id: &'static str,
pub name: &'static str,
pub covers: &'static str,
pub command: &'static str,
pub output_format: TimelineOutputFormat,
pub caveats: &'static [&'static str],
}
pub static TIMELINE_TOOLS: &[TimelineTool] = &[
TimelineTool {
id: "fls",
name: "The Sleuth Kit — fls",
covers: "NTFS/FAT filesystem timestamps ($MFT $STANDARD_INFORMATION); file and directory metadata only",
command: "fls -r -m / {IMAGE} > body.txt",
output_format: TimelineOutputFormat::Bodyfile,
caveats: &[
"File-system only — does not include Event Logs, registry, or execution artifacts",
"Timestamps come from $STANDARD_INFORMATION only; add -f ntfs for $FILE_NAME column",
"Run inside WSL on Windows; use /mnt/... paths, not C:\\ style paths",
"Deleted files appear with (deleted) prefix and may have partial metadata",
],
},
TimelineTool {
id: "mactime",
name: "The Sleuth Kit — mactime",
covers: "Bodyfile consumer; sorts and filters MACB entries from fls/MFTECmd output",
command: "mactime -b body.txt -d > timeline.csv",
output_format: TimelineOutputFormat::MacTimeCsv,
caveats: &[
"Input is a bodyfile; mactime itself performs no parsing — it only sorts and formats",
"-d flag outputs CSV; omit for human-readable table format",
"-z flag specifies timezone (default UTC); always use -z UTC for forensic outputs",
],
},
TimelineTool {
id: "log2timeline",
name: "Plaso — log2timeline.py",
covers: "Super timeline: filesystem, Event Logs, registry, Prefetch, browser history, \
LNK files, Jump Lists, SRUM, AmCache, ActivitiesCache, Recycle Bin, and more",
command: "log2timeline.py {OUTPUT}.plaso {IMAGE}",
output_format: TimelineOutputFormat::PlasoStore,
caveats: &[
"Processing a full disk image can take 30–90+ minutes depending on image size",
"If processing completes in < 7 min with a 316 KB .plaso and 1 KB CSV, the image \
failed to open; use pinfo to inspect warnings — verify path uses /mnt/... notation in WSL",
"LinuxHostnameFile error on start means plaso-tools install is broken; \
run: sudo apt purge plaso-tools && sudo apt autoremove && sudo apt install plaso-tools",
"Pass --vss_stores all to include Volume Shadow Copies, extending the $UsnJrnl window",
"Output is a binary .plaso store; post-process with psort.py to get human-readable CSV",
],
},
TimelineTool {
id: "psort",
name: "Plaso — psort.py",
covers: "Post-processes a .plaso store into filtered, sorted, human-readable output",
command: "psort.py -o l2tcsv {OUTPUT}.plaso > supertimeline.csv",
output_format: TimelineOutputFormat::L2tCsv,
caveats: &[
"-o l2tcsv produces the industry-standard L2T CSV format readable by Timeline Explorer",
"Apply --slice and --slice_size to focus on a time window and reduce noise",
"Use pinfo.py first to inspect the .plaso store for parser warnings and event counts",
],
},
TimelineTool {
id: "mftecmd_body",
name: "MFTECmd — bodyfile export",
covers: "$MFT entries (MACB timestamps, filename, size, MFT record number, allocated/deleted flag)",
command: r"MFTECmd.exe -f \\.\C: --body out\ --bodyf mft.body --blf",
output_format: TimelineOutputFormat::Bodyfile,
caveats: &[
"Escape $ in PowerShell: MFTECmd.exe -f C:\\...\\`$MFT",
"--blf includes both $STANDARD_INFORMATION and $FILE_NAME timestamps (two rows per file)",
"Pass -m flag when processing $UsnJrnl to resolve parent paths from $MFT",
"MFTECmd does not yet parse $LogFile; use for $MFT, $UsnJrnl, $I30 only",
],
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PlasoParser {
pub name: &'static str,
pub description: &'static str,
pub artifact_ids: &'static [&'static str],
}
pub static PLASO_PARSERS: &[PlasoParser] = &[
PlasoParser {
name: "winevtx",
description: "Windows XML Event Log (.evtx) files",
artifact_ids: &["evtx_security", "evtx_system", "evtx_powershell"],
},
PlasoParser {
name: "winreg",
description: "Windows Registry hives (NTUSER.DAT, SYSTEM, SOFTWARE, etc.)",
artifact_ids: &[
"userassist_exe", "shimcache", "amcache_app_file",
"bam_user", "muicache", "run_key_hklm", "run_key_hkcu",
],
},
PlasoParser {
name: "prefetch",
description: "Windows Prefetch files (.pf) — execution timestamps and referenced DLLs",
artifact_ids: &["prefetch_file", "prefetch_dir"],
},
PlasoParser {
name: "lnk",
description: "LNK / Shell Link shortcut files — accessed file paths and timestamps",
artifact_ids: &["lnk_files"],
},
PlasoParser {
name: "custom_destinations",
description: "Jump Lists (custom destinations) — pinned and recently accessed files per app",
artifact_ids: &["jump_list_custom"],
},
PlasoParser {
name: "automatic_destinations",
description: "Jump Lists (automatic destinations) — recently accessed files per app",
artifact_ids: &["jump_list_auto"],
},
PlasoParser {
name: "mft",
description: "$MFT entries — MACB timestamps for every file on the volume",
artifact_ids: &["mft_file"],
},
PlasoParser {
name: "usnjrnl",
description: "$UsnJrnl/$J — file creation, modification, rename, and deletion events",
artifact_ids: &["usnjrnl_file"],
},
PlasoParser {
name: "recycle_bin",
description: "Recycle Bin $I metadata files — deleted file paths and deletion timestamps",
artifact_ids: &["recycle_bin"],
},
PlasoParser {
name: "srum",
description: "System Resource Usage Monitor (SRUDB.dat) — per-app CPU and network usage",
artifact_ids: &["srum_db", "srum_app_resource", "srum_network_data"],
},
PlasoParser {
name: "windows_timeline",
description: "ActivitiesCache.db (Windows Timeline) — user activity across devices",
artifact_ids: &["activities_cache"],
},
PlasoParser {
name: "chrome_history",
description: "Google Chrome / Chromium browser history, downloads, and search terms",
artifact_ids: &["chrome_history"],
},
PlasoParser {
name: "firefox_history",
description: "Mozilla Firefox browser history, downloads, and cookies",
artifact_ids: &["firefox_history"],
},
PlasoParser {
name: "filestat",
description: "Generic file system stat metadata — MACB timestamps for files on disk",
artifact_ids: &["mft_file"],
},
];
pub fn parsers_for_artifact(artifact_id: &str) -> Vec<&'static PlasoParser> {
PLASO_PARSERS
.iter()
.filter(|p| p.artifact_ids.contains(&artifact_id))
.collect()
}
pub fn plaso_parser_by_name(name: &str) -> Option<&'static PlasoParser> {
PLASO_PARSERS.iter().find(|p| p.name == name)
}
pub fn tool_by_id(id: &str) -> Option<&'static TimelineTool> {
TIMELINE_TOOLS.iter().find(|t| t.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bodyfile_has_eleven_fields() {
assert_eq!(BODYFILE_FIELDS.len(), 11);
}
#[test]
fn bodyfile_field_indices_are_sequential() {
for (i, f) in BODYFILE_FIELDS.iter().enumerate() {
assert_eq!(f.index as usize, i, "field '{}' has wrong index", f.name);
}
}
#[test]
fn bodyfile_macb_fields_present() {
let names: Vec<&str> = BODYFILE_FIELDS.iter().map(|f| f.name).collect();
assert!(names.contains(&"atime"), "missing atime (A)");
assert!(names.contains(&"mtime"), "missing mtime (M)");
assert!(names.contains(&"ctime"), "missing ctime (C)");
assert!(names.contains(&"crtime"), "missing crtime (B)");
}
#[test]
fn all_four_tools_present() {
let ids: Vec<&str> = TIMELINE_TOOLS.iter().map(|t| t.id).collect();
assert!(ids.contains(&"fls"), "missing fls");
assert!(ids.contains(&"mactime"), "missing mactime");
assert!(ids.contains(&"log2timeline"), "missing log2timeline");
assert!(ids.contains(&"psort"), "missing psort");
assert!(ids.contains(&"mftecmd_body"), "missing mftecmd_body");
}
#[test]
fn tool_by_id_log2timeline() {
let t = tool_by_id("log2timeline").expect("log2timeline must exist");
assert_eq!(t.output_format, TimelineOutputFormat::PlasoStore);
assert!(!t.caveats.is_empty());
}
#[test]
fn tool_by_id_unknown_returns_none() {
assert!(tool_by_id("nonexistent").is_none());
}
#[test]
fn fls_output_is_bodyfile() {
let fls = tool_by_id("fls").unwrap();
assert_eq!(fls.output_format, TimelineOutputFormat::Bodyfile);
}
#[test]
fn log2timeline_caveat_mentions_wsl_path() {
let t = tool_by_id("log2timeline").unwrap();
let all_caveats = t.caveats.join(" ");
assert!(
all_caveats.contains("/mnt/"),
"log2timeline caveats should mention WSL /mnt/ path requirement"
);
}
#[test]
fn plaso_parsers_nonempty() {
assert!(!PLASO_PARSERS.is_empty());
}
#[test]
fn winevtx_parser_covers_security_evtx() {
let parsers = parsers_for_artifact("evtx_security");
assert!(
parsers.iter().any(|p| p.name == "winevtx"),
"winevtx should cover evtx_security"
);
}
#[test]
fn prefetch_parser_covers_prefetch_file() {
let parsers = parsers_for_artifact("prefetch_file");
assert!(
parsers.iter().any(|p| p.name == "prefetch"),
"prefetch parser should cover prefetch_file"
);
}
#[test]
fn mft_parser_covers_mft_file() {
let parsers = parsers_for_artifact("mft_file");
assert!(
parsers.iter().any(|p| p.name == "mft" || p.name == "filestat"),
"mft or filestat parser should cover mft_file"
);
}
#[test]
fn plaso_parser_by_name_winevtx() {
let p = plaso_parser_by_name("winevtx").expect("winevtx must exist");
assert!(!p.artifact_ids.is_empty());
}
#[test]
fn plaso_parser_by_name_unknown_returns_none() {
assert!(plaso_parser_by_name("nosuchparser").is_none());
}
#[test]
fn all_parser_names_are_unique() {
let mut names: Vec<&str> = PLASO_PARSERS.iter().map(|p| p.name).collect();
let orig_len = names.len();
names.dedup();
assert_eq!(names.len(), orig_len, "duplicate parser names found");
}
#[test]
fn unknown_artifact_returns_empty_parser_list() {
assert!(parsers_for_artifact("no_such_artifact").is_empty());
}
}