Skip to main content

dar_forensic/
findings.rs

1//! DAR forensic findings: severity, anomaly classification, and the analysis
2//! result returned by [`DarAudit::audit`](crate::DarAudit::audit).
3//!
4//! Mirrors the sibling forensic crates (e.g. `iso9660-forensic`): every
5//! anomaly's severity, stable machine-readable code, and human-readable note are
6//! *derived* from its [`AnomalyKind`], so they cannot drift. A disk-forensic
7//! orchestrator can aggregate these uniformly with findings from the partition
8//! and other filesystem layers.
9//!
10//! An anomaly is an *observation*, never an assertion of intent: the notes say
11//! "consistent with …", and the examiner draws the conclusions.
12
13use core::fmt;
14
15/// The canonical 5-level severity scale, shared across every `SecurityRonin`
16/// analyzer via [`forensicnomicon::report`]. Ordered
17/// `Info < Low < Medium < High < Critical`.
18pub use forensicnomicon::report::Severity;
19
20impl forensicnomicon::report::Observation for Anomaly {
21    fn severity(&self) -> Option<Severity> {
22        Some(self.severity)
23    }
24    fn code(&self) -> &'static str {
25        self.code
26    }
27    fn note(&self) -> String {
28        self.note.clone()
29    }
30}
31
32/// Classification of a DAR forensic anomaly.
33///
34/// Each variant carries the evidence needed to reproduce the observation. The
35/// suspicious/benign framing lives in [`AnomalyKind::note`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize))]
38pub enum AnomalyKind {
39    /// Catalogue parsing stopped before a clean root end-of-directory — the
40    /// listing may be truncated. Consistent with a partial/damaged archive or an
41    /// entry type this reader does not model (parsing stops loudly rather than
42    /// silently returning a short listing).
43    IncompleteCatalog {
44        /// Number of entries recovered before parsing stopped.
45        entries_recovered: usize,
46    },
47
48    /// An entry's path is absolute (begins with `/`). DAR stores paths relative
49    /// to the archive root, so an absolute path is unusual; on naive extraction
50    /// it would write outside the destination directory. Consistent with an
51    /// archive crafted to overwrite system paths.
52    AbsolutePath {
53        /// The absolute path (lossy UTF-8).
54        path: String,
55    },
56
57    /// An entry's path contains a `..` parent-directory component. On naive
58    /// extraction this could escape the destination directory — a path-traversal
59    /// ("zip-slip") vector.
60    ParentTraversal {
61        /// The traversing path (lossy UTF-8).
62        path: String,
63    },
64
65    /// More than one catalogue entry records the same path. Consistent with a
66    /// crafted archive in which a later entry shadows an earlier one on
67    /// extraction (the examiner sees one name but two sets of bytes).
68    DuplicatePath {
69        /// The duplicated path (lossy UTF-8).
70        path: String,
71    },
72
73    /// An entry timestamp lies implausibly far in the future (beyond the year
74    /// 2100). Consistent with a misconfigured clock on the archiving host or
75    /// with timestamp tampering.
76    FutureTimestamp {
77        /// Path of the entry (lossy UTF-8).
78        path: String,
79        /// Which timestamp: `atime`, `mtime`, or `ctime`.
80        field: &'static str,
81        /// The timestamp, seconds since the Unix epoch.
82        epoch_secs: i64,
83    },
84
85    /// An entry's name contains non-printable control bytes (below `0x20`, or
86    /// `0x7f`). Consistent with an attempt to obscure the true filename in
87    /// terminal listings (e.g. an embedded escape sequence or carriage return).
88    ControlCharsInName {
89        /// The name as lossy UTF-8 (control bytes become U+FFFD on display).
90        path: String,
91    },
92}
93
94impl AnomalyKind {
95    /// Severity assigned to this kind — the single source of truth.
96    #[must_use]
97    pub fn severity(&self) -> Severity {
98        match self {
99            // A truncated catalogue means entries may be missing entirely.
100            AnomalyKind::IncompleteCatalog { .. } => Severity::High,
101            // Extraction-safety irregularities.
102            AnomalyKind::AbsolutePath { .. } | AnomalyKind::ParentTraversal { .. } => {
103                Severity::Medium
104            }
105            // Listing irregularities with common benign explanations.
106            AnomalyKind::DuplicatePath { .. }
107            | AnomalyKind::FutureTimestamp { .. }
108            | AnomalyKind::ControlCharsInName { .. } => Severity::Low,
109        }
110    }
111
112    /// Stable machine-readable code.
113    #[must_use]
114    pub fn code(&self) -> &'static str {
115        match self {
116            AnomalyKind::IncompleteCatalog { .. } => "DAR-CATALOG-INCOMPLETE",
117            AnomalyKind::AbsolutePath { .. } => "DAR-PATH-ABSOLUTE",
118            AnomalyKind::ParentTraversal { .. } => "DAR-PATH-TRAVERSAL",
119            AnomalyKind::DuplicatePath { .. } => "DAR-PATH-DUPLICATE",
120            AnomalyKind::FutureTimestamp { .. } => "DAR-TIME-FUTURE",
121            AnomalyKind::ControlCharsInName { .. } => "DAR-NAME-CONTROL",
122        }
123    }
124
125    /// Human-readable description (observation, not a conclusion).
126    #[must_use]
127    pub fn note(&self) -> String {
128        match self {
129            AnomalyKind::IncompleteCatalog { entries_recovered } => format!(
130                "catalogue parsing stopped after recovering {entries_recovered} entries, before a \
131                 clean root end-of-directory — the listing may be truncated; consistent with a \
132                 partial or damaged archive, or an entry type this reader does not model"
133            ),
134            AnomalyKind::AbsolutePath { path } => format!(
135                "entry `{path}` has an absolute path — DAR stores paths relative to the archive \
136                 root, so on naive extraction this would write outside the destination directory; \
137                 consistent with an archive crafted to overwrite system paths"
138            ),
139            AnomalyKind::ParentTraversal { path } => format!(
140                "entry `{path}` contains a `..` parent-directory component — on naive extraction \
141                 this could escape the destination directory (a path-traversal / 'zip-slip' vector)"
142            ),
143            AnomalyKind::DuplicatePath { path } => format!(
144                "path `{path}` is recorded by more than one catalogue entry — consistent with a \
145                 crafted archive in which a later entry shadows an earlier one on extraction"
146            ),
147            AnomalyKind::FutureTimestamp { path, field, epoch_secs } => format!(
148                "entry `{path}` {field} is {epoch_secs} (beyond the year 2100) — implausibly far in \
149                 the future; consistent with a misconfigured clock or timestamp tampering"
150            ),
151            AnomalyKind::ControlCharsInName { path } => format!(
152                "entry `{path}` contains non-printable control byte(s) in its name — consistent \
153                 with an attempt to obscure the true filename in a terminal listing"
154            ),
155        }
156    }
157}
158
159/// A single forensic anomaly: an [`AnomalyKind`] with its derived severity,
160/// stable code, and human-readable note.
161#[derive(Debug, Clone, PartialEq, Eq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize))]
163pub struct Anomaly {
164    /// Severity, derived from `kind`.
165    pub severity: Severity,
166    /// Stable machine-readable code, derived from `kind`.
167    pub code: &'static str,
168    /// The classified anomaly with its evidence.
169    pub kind: AnomalyKind,
170    /// Human-readable note, derived from `kind`.
171    pub note: String,
172}
173
174impl Anomaly {
175    /// Build an [`Anomaly`], deriving severity/code/note from `kind` so they
176    /// cannot drift from the classification.
177    #[must_use]
178    pub fn new(kind: AnomalyKind) -> Self {
179        Anomaly {
180            severity: kind.severity(),
181            code: kind.code(),
182            note: kind.note(),
183            kind,
184        }
185    }
186}
187
188impl fmt::Display for Anomaly {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(f, "[{}] {}: {}", self.severity, self.code, self.note)
191    }
192}