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}