Skip to main content

browser_forensic_core/
finding.rs

1//! Court-safe forensic finding model (RFC 0001 — D4 provenance, D5 the
2//! Priority/Confidence/Interpretation split, D9 multi-user origin).
3//!
4//! A [`Finding`] keeps the three epistemic axes **structurally separate** so no
5//! renderer can collapse them into a bare `HIGH` that reads as *high confidence
6//! of wrongdoing*:
7//!
8//! * [`Priority`] — a triage attention cue (*look here first*), never a verdict.
9//! * [`Confidence`] + `rule_id` — how strongly the *interpretation* is supported.
10//! * `interpretation` — the hedged *"consistent with …"* statement.
11//!
12//! Because [`Priority`] and [`Confidence`] are distinct types, the compiler makes
13//! it impossible to pass one where the other belongs. There is deliberately **no
14//! `Display` impl on [`Finding`]** that could emit an absolute; render a finding
15//! with [`Finding::render`], which always shows the three axes separately and
16//! always carries the interpretation hedge.
17
18use serde::{Deserialize, Serialize};
19
20use crate::BrowserFamily;
21
22/// Triage attention cue — *where to look first* (RFC 0001 D5).
23///
24/// Deliberately **not** a confidence or a verdict: `High` means "look here
25/// first," never "high confidence of wrongdoing." Kept a separate type from
26/// [`Confidence`] so the two axes can never be conflated.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub enum Priority {
30    High,
31    Medium,
32    Info,
33}
34
35impl std::fmt::Display for Priority {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::High => write!(f, "High"),
39            Self::Medium => write!(f, "Medium"),
40            Self::Info => write!(f, "Info"),
41        }
42    }
43}
44
45/// How strongly the finding's *interpretation* is supported (RFC 0001 D5).
46/// Always travels with a `rule_id` on the [`Finding`].
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub enum Confidence {
50    High,
51    Medium,
52    Low,
53}
54
55impl std::fmt::Display for Confidence {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::High => write!(f, "High"),
59            Self::Medium => write!(f, "Medium"),
60            Self::Low => write!(f, "Low"),
61        }
62    }
63}
64
65/// Where the datum was read from — a coarse provenance axis (RFC 0001 D4).
66/// A live history hit, a carved string, and a cached resource have different
67/// courtroom value; this axis records which.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70pub enum EvidenceSource {
71    History,
72    Cache,
73    Cookie,
74    Download,
75    Carved,
76    Memory,
77    /// An installed browser extension (manifest / `Extensions` directory).
78    Extension,
79    /// A domain/origin recovered from a network/state artifact that survives a
80    /// history wipe (HTTP server properties, NEL/Reporting, DIPS/BTM, HSTS). Its
81    /// courtroom value differs from a live history visit: contact is *inferred*
82    /// from a persistence side effect, not a recorded user navigation.
83    Recovered,
84}
85
86impl std::fmt::Display for EvidenceSource {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::History => write!(f, "history"),
90            Self::Cache => write!(f, "cache"),
91            Self::Cookie => write!(f, "cookie"),
92            Self::Download => write!(f, "download"),
93            Self::Carved => write!(f, "carved"),
94            Self::Memory => write!(f, "memory"),
95            Self::Extension => write!(f, "extension"),
96            Self::Recovered => write!(f, "recovered"),
97        }
98    }
99}
100
101/// Liveness / derivation state of the datum (RFC 0001 D4).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub enum EvidenceState {
105    Live,
106    Deleted,
107    Carved,
108    Reconstructed,
109    Inferred,
110}
111
112impl std::fmt::Display for EvidenceState {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::Live => write!(f, "live"),
116            Self::Deleted => write!(f, "deleted"),
117            Self::Carved => write!(f, "carved"),
118            Self::Reconstructed => write!(f, "reconstructed"),
119            Self::Inferred => write!(f, "inferred"),
120        }
121    }
122}
123
124/// Basis for the timestamp attached to a finding (RFC 0001 D4/D8).
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub enum TimestampBasis {
128    /// A timestamp the artifact stores explicitly for this record.
129    Explicit,
130    /// Derived from adjacent data, not stored for this record directly.
131    Inferred,
132    /// Taken from a surrounding page/resource rather than the datum itself.
133    SurroundingPage,
134    /// No time basis is available.
135    None,
136}
137
138impl std::fmt::Display for TimestampBasis {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            Self::Explicit => write!(f, "explicit"),
142            Self::Inferred => write!(f, "inferred"),
143            Self::SurroundingPage => write!(f, "surrounding-page"),
144            Self::None => write!(f, "none"),
145        }
146    }
147}
148
149/// The user-action the evidence supports — stated as a *claim*, never a verdict
150/// (RFC 0001 D4). "Observed string" is the weakest: the term merely appeared in
151/// stored bytes, with no proof a human acted on it.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub enum UserActionClaim {
155    Visited,
156    Downloaded,
157    Searched,
158    ObservedString,
159    Unknown,
160}
161
162impl std::fmt::Display for UserActionClaim {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Visited => write!(f, "visited"),
166            Self::Downloaded => write!(f, "downloaded"),
167            Self::Searched => write!(f, "searched"),
168            Self::ObservedString => write!(f, "observed-string"),
169            Self::Unknown => write!(f, "unknown"),
170        }
171    }
172}
173
174/// The four provenance axes (RFC 0001 D4). They travel together so a [`Finding`]
175/// can never be constructed without a full provenance record — no silent,
176/// misleading default.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct Provenance {
180    /// Where the datum was read from.
181    pub source: EvidenceSource,
182    /// Its liveness / derivation state.
183    pub state: EvidenceState,
184    /// The basis for any timestamp on the finding.
185    pub timestamp_basis: TimestampBasis,
186    /// The user-action the evidence supports, as a claim.
187    pub user_action_claim: UserActionClaim,
188}
189
190impl Provenance {
191    /// Build a full provenance record. All four axes are required.
192    #[must_use]
193    pub fn new(
194        source: EvidenceSource,
195        state: EvidenceState,
196        timestamp_basis: TimestampBasis,
197        user_action_claim: UserActionClaim,
198    ) -> Self {
199        Self {
200            source,
201            state,
202            timestamp_basis,
203            user_action_claim,
204        }
205    }
206}
207
208/// A court-safe forensic finding (RFC 0001 D4/D5/D9).
209///
210/// Priority, Confidence and Interpretation are three structurally separate axes;
211/// provenance ([`Provenance`]) and origin (`user`/`profile`/`browser`) stamp
212/// every finding with where it came from. Render with [`Finding::render`].
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct Finding {
216    /// Triage attention cue — *look here first*, not a verdict.
217    pub priority: Priority,
218    /// Confidence in the interpretation (paired with `rule_id`).
219    pub confidence: Confidence,
220    /// Identifier of the rule that produced this finding.
221    pub rule_id: String,
222    /// The hedged *"consistent with …"* statement.
223    pub interpretation: String,
224    /// The four-axis provenance record (D4).
225    pub provenance: Provenance,
226    /// Originating user (SID or name), when known (D9).
227    pub user: Option<String>,
228    /// Originating browser profile (e.g. `Chrome/Default`), when known (D9).
229    pub profile: Option<String>,
230    /// Originating browser family, when known (D9).
231    pub browser: Option<BrowserFamily>,
232    /// The concrete datum this finding rests on
233    /// (e.g. `Chrome History urls rowid gap 128 → 944`).
234    pub evidence: String,
235    /// A drill-down command pointer for the examiner's next step.
236    pub next: Option<String>,
237}
238
239impl Finding {
240    /// Build a finding from its three separate axes, a full provenance record,
241    /// and the concrete evidence datum.
242    ///
243    /// [`Priority`] and [`Confidence`] are distinct types, so the three axes
244    /// cannot be conflated at a call site. Origin (`user`/`profile`/`browser`)
245    /// and `next` are attached with the `with_*` builder methods.
246    #[must_use]
247    pub fn new(
248        priority: Priority,
249        confidence: Confidence,
250        rule_id: impl Into<String>,
251        interpretation: impl Into<String>,
252        provenance: Provenance,
253        evidence: impl Into<String>,
254    ) -> Self {
255        Self {
256            priority,
257            confidence,
258            rule_id: rule_id.into(),
259            interpretation: interpretation.into(),
260            provenance,
261            user: None,
262            profile: None,
263            browser: None,
264            evidence: evidence.into(),
265            next: None,
266        }
267    }
268
269    /// Stamp the originating user (SID or name) (D9).
270    #[must_use]
271    pub fn with_user(mut self, user: impl Into<String>) -> Self {
272        self.user = Some(user.into());
273        self
274    }
275
276    /// Stamp the originating browser profile (D9).
277    #[must_use]
278    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
279        self.profile = Some(profile.into());
280        self
281    }
282
283    /// Stamp the originating browser family (D9).
284    #[must_use]
285    pub fn with_browser(mut self, browser: BrowserFamily) -> Self {
286        self.browser = Some(browser);
287        self
288    }
289
290    /// Attach a drill-down "next step" command pointer.
291    #[must_use]
292    pub fn with_next(mut self, next: impl Into<String>) -> Self {
293        self.next = Some(next.into());
294        self
295    }
296
297    /// Render the finding as a multi-line, court-safe block.
298    ///
299    /// The three axes are always shown separately and labelled; Priority is
300    /// explicitly framed as a triage attention cue (never a verdict); the
301    /// Interpretation hedge is always present. This is the only renderer for a
302    /// finding — there is no `Display` impl that could collapse it into a bare
303    /// conclusion. (RFC 0001 D5.)
304    #[must_use]
305    pub fn render(&self) -> String {
306        use std::fmt::Write as _;
307        let mut out = String::new();
308        // Writing to a String is infallible; the `_ =` keeps that explicit.
309        let _ = writeln!(
310            out,
311            "Priority:       {}  (look here first — a triage attention cue)",
312            self.priority
313        );
314        let _ = writeln!(
315            out,
316            "Confidence:     {}  (rule {})",
317            self.confidence, self.rule_id
318        );
319        let _ = writeln!(out, "Interpretation: {}", self.interpretation);
320        let _ = writeln!(out, "Rule:           {}", self.rule_id);
321        let p = &self.provenance;
322        let _ = writeln!(
323            out,
324            "Provenance:     {} · {} · time {} · {}",
325            p.source, p.state, p.timestamp_basis, p.user_action_claim
326        );
327        if let Some(origin) = self.origin_line() {
328            let _ = writeln!(out, "Origin:         {origin}");
329        }
330        let _ = writeln!(out, "Evidence:       {}", self.evidence);
331        if let Some(next) = &self.next {
332            let _ = writeln!(out, "Next:           {next}");
333        }
334        out
335    }
336
337    /// Compose the D9 origin line from whichever of browser/profile/user are
338    /// known. Returns `None` when the finding carries no origin stamp.
339    fn origin_line(&self) -> Option<String> {
340        let mut parts: Vec<String> = Vec::new();
341        if let Some(browser) = &self.browser {
342            parts.push(browser.to_string());
343        }
344        if let Some(profile) = &self.profile {
345            parts.push(profile.clone());
346        }
347        if let Some(user) = &self.user {
348            parts.push(format!("user {user}"));
349        }
350        if parts.is_empty() {
351            None
352        } else {
353            Some(parts.join(" · "))
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn sample_provenance() -> Provenance {
363        Provenance::new(
364            EvidenceSource::History,
365            EvidenceState::Live,
366            TimestampBasis::Explicit,
367            UserActionClaim::Visited,
368        )
369    }
370
371    fn sample_finding() -> Finding {
372        Finding::new(
373            Priority::High,
374            Confidence::Medium,
375            "integrity.history.rowid_gap.v1",
376            "consistent with manual deletion, DB maintenance, or profile sync",
377            sample_provenance(),
378            "Chrome History urls rowid gap 128 → 944",
379        )
380    }
381
382    #[test]
383    fn provenance_carries_four_axes() {
384        let p = sample_provenance();
385        assert_eq!(p.source, EvidenceSource::History);
386        assert_eq!(p.state, EvidenceState::Live);
387        assert_eq!(p.timestamp_basis, TimestampBasis::Explicit);
388        assert_eq!(p.user_action_claim, UserActionClaim::Visited);
389    }
390
391    #[test]
392    fn new_sets_three_distinct_axes() {
393        let f = sample_finding();
394        // Three separate axes, each its own value — a High priority does NOT
395        // imply high confidence.
396        assert_eq!(f.priority, Priority::High);
397        assert_eq!(f.confidence, Confidence::Medium);
398        assert_eq!(f.rule_id, "integrity.history.rowid_gap.v1");
399        assert!(f.interpretation.starts_with("consistent with"));
400    }
401
402    #[test]
403    fn origin_builder_sets_user_profile_browser() {
404        let f = sample_finding()
405            .with_user("S-1-5-21-1004")
406            .with_profile("Chrome/Default")
407            .with_browser(BrowserFamily::Chromium)
408            .with_next("br4n6 artifact integrity --rule history.rowid_gap <PATH>");
409        assert_eq!(f.user.as_deref(), Some("S-1-5-21-1004"));
410        assert_eq!(f.profile.as_deref(), Some("Chrome/Default"));
411        assert_eq!(f.browser, Some(BrowserFamily::Chromium));
412        assert!(f.next.is_some());
413    }
414
415    #[test]
416    fn roundtrip_json_preserves_all_fields() {
417        let f = sample_finding()
418            .with_user("alice")
419            .with_profile("Chrome/Default")
420            .with_browser(BrowserFamily::Chromium)
421            .with_next("br4n6 artifact integrity <PATH>");
422        let json = serde_json::to_string(&f).expect("serialize");
423        let back: Finding = serde_json::from_str(&json).expect("deserialize");
424        assert_eq!(f, back, "JSONL round-trip must be faithful");
425    }
426
427    #[test]
428    fn three_axes_serialize_as_distinct_top_level_fields() {
429        let f = sample_finding();
430        let v = serde_json::to_value(&f).expect("to_value");
431        let obj = v.as_object().expect("finding serializes as an object");
432        // The three D5 axes are separate keys carrying separate values — a
433        // renderer reading this can never collapse them into one "HIGH".
434        assert_eq!(obj.get("priority").and_then(|x| x.as_str()), Some("High"));
435        assert_eq!(
436            obj.get("confidence").and_then(|x| x.as_str()),
437            Some("Medium")
438        );
439        assert!(
440            obj.get("interpretation").is_some(),
441            "interpretation is a distinct field"
442        );
443        assert!(obj.contains_key("rule_id"), "rule_id is a distinct field");
444        // Provenance is a distinct, structured sub-record present in JSONL (D4).
445        let prov = obj
446            .get("provenance")
447            .and_then(|x| x.as_object())
448            .expect("provenance object present");
449        for key in ["source", "state", "timestamp_basis", "user_action_claim"] {
450            assert!(prov.contains_key(key), "provenance carries `{key}`");
451        }
452    }
453
454    #[test]
455    fn render_shows_all_three_axes_with_labels() {
456        let f = sample_finding();
457        let r = f.render();
458        assert!(r.contains("Priority:"), "labels the priority axis: {r}");
459        assert!(r.contains("Confidence:"), "labels the confidence axis");
460        assert!(r.contains("Interpretation:"), "labels the interpretation");
461        assert!(
462            r.contains("integrity.history.rowid_gap.v1"),
463            "shows rule id"
464        );
465        assert!(
466            r.contains("Chrome History urls rowid gap"),
467            "shows evidence"
468        );
469    }
470
471    #[test]
472    fn render_labels_priority_as_attention_cue() {
473        let f = sample_finding();
474        let r = f.render();
475        // The word "High" must never stand as a bare verdict: the priority line
476        // frames it as a triage attention cue.
477        assert!(
478            r.contains("attention cue"),
479            "priority is framed as a triage attention cue, not a finding of malice: {r}"
480        );
481    }
482
483    #[test]
484    fn render_priority_never_appears_without_interpretation_hedge() {
485        let f = sample_finding();
486        let r = f.render();
487        // Whenever the render shows a Priority, the hedged interpretation is
488        // present in the same block — the conclusion can never be read bare.
489        assert!(r.contains("Priority:"));
490        assert!(
491            r.contains(&f.interpretation),
492            "the interpretation hedge accompanies every rendered priority: {r}"
493        );
494    }
495}