Skip to main content

trash_forensic/
windows.rs

1//! Forensic anomaly analysis for Windows Recycle Bin `$I`/`$R` artifacts.
2//!
3//! [`trash_core::windows`] is the lean reader: it parses a `$I` index file into a
4//! [`RecycleBinIndex`] and pairs `$I`/`$R` files. This module is the
5//! evidence-grade layer on top — it inspects a parsed record + its pairing and
6//! reports anomalies as canonical [`forensicnomicon::report::Finding`]s.
7//!
8//! | Code | Category | Meaning |
9//! |---|---|---|
10//! | `RECYCLEBIN-CONTENT-PURGED` | Residue | `$I` metadata survives but the `$R` content file is gone |
11//! | `RECYCLEBIN-PATH-TRAVERSAL` | Concealment | the stored original path escapes its directory (`..\`) |
12//! | `RECYCLEBIN-DELETION-TIME-MISSING` | Integrity | the `FILETIME` deletion time is zero (unset / broken) |
13//!
14//! Findings are observations, never legal conclusions: the analyst concludes.
15
16use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
17use trash_core::{RecycleBinIndex, RecycleBinPair};
18
19use crate::{has_path_traversal, ANALYZER};
20
21/// A Recycle Bin anomaly, with the offending evidence attached.
22///
23/// The reader keeps its typed reader output; this analyzer keeps its typed
24/// anomaly kind (domain knowledge) and converts to canonical findings.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum AnomalyKind {
27    /// A `$I` index file with no paired `$R` content file: the metadata of a
28    /// deleted file survives but its content has already been purged.
29    ContentPurged {
30        /// The original path recorded in the surviving `$I`.
31        original_path: String,
32    },
33    /// The original path stored in the `$I` file escapes its directory via
34    /// `..\` (or `../`) components — consistent with a crafted name rather than
35    /// a normal shell deletion.
36    PathTraversal {
37        /// The offending stored path, surfaced verbatim for the investigator.
38        original_path: String,
39    },
40    /// The `FILETIME` deletion timestamp is zero — recorded but never set.
41    DeletionTimeMissing {
42        /// The path of the record whose deletion time is missing.
43        original_path: String,
44    },
45}
46
47impl AnomalyKind {
48    /// Stable, scheme-prefixed machine code (a published contract).
49    #[must_use]
50    pub fn code(&self) -> &'static str {
51        match self {
52            AnomalyKind::ContentPurged { .. } => "RECYCLEBIN-CONTENT-PURGED",
53            AnomalyKind::PathTraversal { .. } => "RECYCLEBIN-PATH-TRAVERSAL",
54            AnomalyKind::DeletionTimeMissing { .. } => "RECYCLEBIN-DELETION-TIME-MISSING",
55        }
56    }
57
58    /// Canonical severity for the anomaly.
59    #[must_use]
60    pub fn severity(&self) -> Severity {
61        match self {
62            AnomalyKind::ContentPurged { .. } => Severity::Medium,
63            AnomalyKind::PathTraversal { .. } => Severity::High,
64            AnomalyKind::DeletionTimeMissing { .. } => Severity::Low,
65        }
66    }
67
68    /// Analytical lens for the anomaly.
69    #[must_use]
70    pub fn category(&self) -> Category {
71        match self {
72            AnomalyKind::ContentPurged { .. } => Category::Residue,
73            AnomalyKind::PathTraversal { .. } => Category::Concealment,
74            AnomalyKind::DeletionTimeMissing { .. } => Category::Integrity,
75        }
76    }
77
78    /// Human-readable, consistent-with note.
79    #[must_use]
80    pub fn note(&self) -> String {
81        match self {
82            AnomalyKind::ContentPurged { original_path } => format!(
83                "$I index for {original_path} survives but its $R content file is absent — \
84                 consistent with the file's content having been purged while its metadata remains"
85            ),
86            AnomalyKind::PathTraversal { original_path } => format!(
87                "stored original path {original_path} contains parent-directory ('..') \
88                 components — consistent with a crafted name rather than a normal deletion"
89            ),
90            AnomalyKind::DeletionTimeMissing { original_path } => format!(
91                "deletion FILETIME for {original_path} is zero (unset) — the deletion time \
92                 was not recorded or has been cleared"
93            ),
94        }
95    }
96
97    /// Convert this anomaly into a canonical [`Finding`].
98    fn to_finding(&self, source: Source) -> Finding {
99        let path = match self {
100            AnomalyKind::ContentPurged { original_path }
101            | AnomalyKind::PathTraversal { original_path }
102            | AnomalyKind::DeletionTimeMissing { original_path } => original_path.clone(),
103        };
104        Finding::observation(self.severity(), self.category(), self.code())
105            .note(self.note())
106            .source(source)
107            .evidence_item(Evidence {
108                field: "original_path".to_string(),
109                value: path.clone(),
110                location: Some(Location::Path(path)),
111            })
112            .build()
113    }
114}
115
116/// Build the [`Source`] stamped on every finding (analyzer + version + scope).
117fn source_for(pair: &RecycleBinPair) -> Source {
118    let scope = pair
119        .index_path
120        .file_name()
121        .and_then(|n| n.to_str())
122        .unwrap_or("$I")
123        .to_string();
124    Source {
125        analyzer: ANALYZER.to_string(),
126        scope,
127        version: Some(env!("CARGO_PKG_VERSION").to_string()),
128    }
129}
130
131/// Audit a parsed `$I` record together with its `$I`/`$R` pairing, returning a
132/// canonical [`Finding`] for each anomaly detected.
133///
134/// Detects a purged content file (`$I` without `$R`), a path-traversal stored
135/// name, and a missing (zero) deletion time. A well-formed record with content
136/// and a deletion time yields no findings.
137#[must_use]
138pub fn audit_pair(index: &RecycleBinIndex, pair: &RecycleBinPair) -> Vec<Finding> {
139    let source = source_for(pair);
140    let mut anomalies = Vec::new();
141
142    if pair.content_path.is_none() {
143        anomalies.push(AnomalyKind::ContentPurged {
144            original_path: index.original_path.clone(),
145        });
146    }
147
148    if has_path_traversal(&index.original_path) {
149        anomalies.push(AnomalyKind::PathTraversal {
150            original_path: index.original_path.clone(),
151        });
152    }
153
154    if index.deleted_at.is_none() {
155        anomalies.push(AnomalyKind::DeletionTimeMissing {
156            original_path: index.original_path.clone(),
157        });
158    }
159
160    anomalies
161        .iter()
162        .map(|a| a.to_finding(source.clone()))
163        .collect()
164}