Skip to main content

browser_forensic_core/
lib.rs

1#![cfg_attr(
2    test,
3    allow(
4        clippy::unwrap_used,
5        clippy::expect_used,
6        clippy::no_effect_underscore_binding
7    )
8)]
9//! Core types for browser forensic analysis.
10
11pub mod analyze;
12pub mod finding;
13pub mod reconstruct;
14pub mod sqlite;
15pub mod test_utils;
16pub mod timestamp;
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23pub use forensicnomicon::evidence::EvidenceStrength;
24
25pub use finding::{
26    Confidence, EvidenceSource, EvidenceState, Finding, Priority, Provenance, TimestampBasis,
27    UserActionClaim,
28};
29
30/// Browser engine family.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub enum BrowserFamily {
34    Chromium,
35    Firefox,
36    Safari,
37    /// Internet Explorer (Trident) — history/cookies/cache live in the ESE
38    /// `WebCacheV01.dat`, not SQLite.
39    InternetExplorer,
40    /// Legacy (EdgeHTML/Spartan) Microsoft Edge — shares the ESE
41    /// `WebCacheV01.dat` store with Internet Explorer.
42    EdgeLegacy,
43}
44
45impl std::fmt::Display for BrowserFamily {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Chromium => write!(f, "Chromium"),
49            Self::Firefox => write!(f, "Firefox"),
50            Self::Safari => write!(f, "Safari"),
51            Self::InternetExplorer => write!(f, "Internet Explorer"),
52            Self::EdgeLegacy => write!(f, "Edge (Legacy)"),
53        }
54    }
55}
56
57/// Kind of browser artifact.
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60pub enum ArtifactKind {
61    History,
62    Cookies,
63    Downloads,
64    Extensions,
65    LoginData,
66    Cache,
67    Bookmarks,
68    Autofill,
69    Session,
70    Integrity,
71    Carved,
72    Memory,
73    Preferences,
74    LocalStorage,
75    /// Per-site permission grant (geolocation, camera, mic, notifications, …).
76    Permission,
77    /// Stored payment-card metadata. The card number is never decrypted.
78    CreditCard,
79    /// OAuth / sync auth-token metadata. The token itself is never decrypted.
80    AuthToken,
81    /// A domain/origin the browser contacted, recovered from a network/state
82    /// artifact that survives a history wipe (HTTP server properties, NEL/
83    /// Reporting, DIPS/BTM bounce records, HSTS). Read-only, no secrets.
84    RecoveredDomain,
85    /// A page the browser stored a favicon for (Chromium `Favicons`). The
86    /// `page_url` is an independent, cleartext source of visited URLs.
87    Favicon,
88    /// A most-visited page cached for the new-tab page (Chromium `Top Sites`).
89    /// Frecency-ranked; no per-visit timestamp.
90    TopSite,
91    /// A string the user typed into the omnibox and the URL they selected
92    /// (Chromium `Shortcuts`). Direct evidence of user intent.
93    Shortcut,
94    /// A (often partial) omnibox string the user typed and the URL Chromium
95    /// learned to predict (Chromium `Network Action Predictor`).
96    NetworkPrediction,
97    /// Audio/video playback recorded by Chromium `Media History` (watch time,
98    /// resume position, media title).
99    MediaPlayback,
100    /// A string the user typed into the address bar and the page it resolved to
101    /// (Firefox `moz_inputhistory`). Direct evidence of typed intent; carries a
102    /// decayed `use_count`, not a per-keystroke timestamp.
103    TypedInput,
104    /// A page annotation recorded by Firefox (`moz_annos` +
105    /// `moz_anno_attributes`): a named key/value the browser attached to a page
106    /// (reading-list state, visit-count metadata, …). Stated as recorded.
107    Annotation,
108    /// A bookmark found in a Firefox `bookmarkbackups/*.jsonlz4` backup but
109    /// absent from the current `moz_bookmarks` — consistent with deletion after
110    /// that backup was written. The backup date bounds *when*, not who or why.
111    RecoveredBookmark,
112}
113
114impl std::fmt::Display for ArtifactKind {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Self::History => write!(f, "History"),
118            Self::Cookies => write!(f, "Cookies"),
119            Self::Downloads => write!(f, "Downloads"),
120            Self::Extensions => write!(f, "Extensions"),
121            Self::LoginData => write!(f, "LoginData"),
122            Self::Cache => write!(f, "Cache"),
123            Self::Bookmarks => write!(f, "Bookmarks"),
124            Self::Autofill => write!(f, "Autofill"),
125            Self::Session => write!(f, "Session"),
126            Self::Integrity => write!(f, "Integrity"),
127            Self::Carved => write!(f, "Carved"),
128            Self::Memory => write!(f, "Memory"),
129            Self::Preferences => write!(f, "Preferences"),
130            Self::LocalStorage => write!(f, "LocalStorage"),
131            Self::Permission => write!(f, "Permission"),
132            Self::CreditCard => write!(f, "CreditCard"),
133            Self::AuthToken => write!(f, "AuthToken"),
134            Self::RecoveredDomain => write!(f, "RecoveredDomain"),
135            Self::Favicon => write!(f, "Favicon"),
136            Self::TopSite => write!(f, "TopSite"),
137            Self::Shortcut => write!(f, "Shortcut"),
138            Self::NetworkPrediction => write!(f, "NetworkPrediction"),
139            Self::MediaPlayback => write!(f, "MediaPlayback"),
140            Self::TypedInput => write!(f, "TypedInput"),
141            Self::Annotation => write!(f, "Annotation"),
142            Self::RecoveredBookmark => write!(f, "RecoveredBookmark"),
143        }
144    }
145}
146
147/// A single browser forensic event.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub struct BrowserEvent {
151    pub timestamp_ns: i64,
152    pub browser: BrowserFamily,
153    pub artifact: ArtifactKind,
154    pub source: String,
155    pub description: String,
156    pub attrs: HashMap<String, serde_json::Value>,
157}
158
159impl BrowserEvent {
160    #[must_use]
161    pub fn new(
162        timestamp_ns: i64,
163        browser: BrowserFamily,
164        artifact: ArtifactKind,
165        source: impl Into<String>,
166        description: impl Into<String>,
167    ) -> Self {
168        Self {
169            timestamp_ns,
170            browser,
171            artifact,
172            source: source.into(),
173            description: description.into(),
174            attrs: HashMap::new(),
175        }
176    }
177
178    #[must_use]
179    pub fn with_attr(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
180        self.attrs.insert(key.into(), value);
181        self
182    }
183}
184
185/// Generate the JSON Schema (draft 2020-12) for [`BrowserEvent`] and its
186/// sub-types.
187///
188/// The schema is *derived* from the Rust types via schemars, so it never drifts
189/// from the serialized shape. `br4n6 schema` emits it, and a sync test keeps the
190/// committed `docs/browserevent.schema.json` in step.
191#[cfg(feature = "schema")]
192#[must_use]
193pub fn browser_event_schema() -> schemars::Schema {
194    schemars::schema_for!(BrowserEvent)
195}
196
197/// Generate the JSON Schema (draft 2020-12) for [`Finding`] and its sub-types.
198///
199/// Like [`browser_event_schema`], the schema is *derived* from the Rust types via
200/// schemars, so it never drifts from the serialized shape. The committed
201/// `docs/finding.schema.json` is kept in step by a sync test.
202#[cfg(feature = "schema")]
203#[must_use]
204pub fn finding_schema() -> schemars::Schema {
205    schemars::schema_for!(Finding)
206}
207
208/// Forensic metadata from forensicnomicon for a specific browser artifact.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ForensicMeta {
211    pub artifact_id: String,
212    pub evidence_strength: Option<String>,
213    pub volatility: Option<String>,
214    pub caveats: Vec<String>,
215}
216
217impl ForensicMeta {
218    /// Look up forensic metadata for the given artifact ID.
219    /// Returns `None` if the artifact is not in forensicnomicon's catalog.
220    #[must_use]
221    pub fn lookup(artifact_id: &str) -> Option<Self> {
222        let desc = forensicnomicon::evidence::evidence_for(artifact_id)?;
223        Some(Self {
224            artifact_id: artifact_id.to_string(),
225            evidence_strength: desc.evidence_strength.map(|s| format!("{s:?}")),
226            volatility: desc.volatility.map(|v| format!("{v:?}")),
227            caveats: desc
228                .evidence_caveats
229                .iter()
230                .map(|c| (*c).to_string())
231                .collect(),
232        })
233    }
234}
235
236/// Detect the browser family from a file path.
237///
238/// Returns `None` if the path does not match a known browser artifact.
239#[must_use]
240pub fn detect_browser(path: &Path) -> Option<BrowserFamily> {
241    let name = path.file_name()?.to_string_lossy().to_lowercase();
242    let path_str = path.to_string_lossy().to_lowercase();
243
244    // Safari
245    if path_str.contains("safari") {
246        let safari_files = [
247            "history.db",
248            "cookies.db",
249            "downloads.plist",
250            "bookmarks.plist",
251        ];
252        if safari_files.contains(&name.as_str()) {
253            return Some(BrowserFamily::Safari);
254        }
255    }
256
257    // Chromium family
258    let chromium_vendors = [
259        "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "arc",
260    ];
261    let is_chromium_path = chromium_vendors.iter().any(|b| path_str.contains(b));
262    let chromium_files = ["history", "cookies", "login data", "web data", "bookmarks"];
263    if chromium_files.contains(&name.as_str()) && is_chromium_path {
264        return Some(BrowserFamily::Chromium);
265    }
266
267    // Firefox family
268    if name == "places.sqlite" || name == "formhistory.sqlite" {
269        return Some(BrowserFamily::Firefox);
270    }
271    let firefox_files = [
272        "cookies.sqlite",
273        "logins.json",
274        "extensions.json",
275        "sessionstore.jsonlz4",
276    ];
277    if firefox_files.contains(&name.as_str())
278        && (path_str.contains("firefox") || path_str.contains("mozilla"))
279    {
280        return Some(BrowserFamily::Firefox);
281    }
282
283    None
284}
285
286#[cfg(all(test, feature = "schema"))]
287mod schema_tests {
288    #[test]
289    fn browser_event_schema_describes_the_event_fields() {
290        let schema = super::browser_event_schema();
291        let json = serde_json::to_value(&schema).expect("schema serializes");
292        let props = json
293            .get("properties")
294            .and_then(serde_json::Value::as_object)
295            .expect("BrowserEvent schema has a properties object");
296        for field in [
297            "timestamp_ns",
298            "browser",
299            "artifact",
300            "source",
301            "description",
302            "attrs",
303        ] {
304            assert!(
305                props.contains_key(field),
306                "schema should describe the `{field}` field of BrowserEvent"
307            );
308        }
309        // The sub-types must be present as reusable definitions.
310        let defs = json
311            .get("$defs")
312            .and_then(serde_json::Value::as_object)
313            .expect("schema exposes $defs for the sub-types");
314        assert!(defs.contains_key("BrowserFamily"), "BrowserFamily in $defs");
315        assert!(defs.contains_key("ArtifactKind"), "ArtifactKind in $defs");
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn detect_chrome_history() {
325        let p = Path::new("/home/user/.config/google-chrome/Default/History");
326        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
327    }
328
329    #[test]
330    fn detect_edge_history() {
331        let p = Path::new("/home/user/.config/microsoft-edge/Default/History");
332        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
333    }
334
335    #[test]
336    fn detect_firefox_places() {
337        let p = Path::new("/home/user/.mozilla/firefox/abc.default/places.sqlite");
338        assert_eq!(detect_browser(p), Some(BrowserFamily::Firefox));
339    }
340
341    #[test]
342    fn detect_firefox_cookies() {
343        let p = Path::new("/home/user/.mozilla/firefox/abc.default/cookies.sqlite");
344        assert_eq!(detect_browser(p), Some(BrowserFamily::Firefox));
345    }
346
347    #[test]
348    fn detect_unknown_returns_none() {
349        assert_eq!(detect_browser(Path::new("/tmp/foo.db")), None);
350    }
351
352    #[test]
353    fn browser_family_has_safari_variant() {
354        let _safari = BrowserFamily::Safari;
355    }
356
357    #[test]
358    fn artifact_kind_has_bookmarks() {
359        let _bk = ArtifactKind::Bookmarks;
360    }
361
362    #[test]
363    fn artifact_kind_has_autofill() {
364        let _af = ArtifactKind::Autofill;
365    }
366
367    #[test]
368    fn artifact_kind_has_session() {
369        let _s = ArtifactKind::Session;
370    }
371
372    #[test]
373    fn detect_safari_history_db() {
374        let p = Path::new("/Users/test/Library/Safari/History.db");
375        assert_eq!(detect_browser(p), Some(BrowserFamily::Safari));
376    }
377
378    #[test]
379    fn detect_brave_history() {
380        let p = Path::new(
381            "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser/Default/History",
382        );
383        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
384    }
385
386    #[test]
387    fn browser_family_display() {
388        assert_eq!(format!("{}", BrowserFamily::Chromium), "Chromium");
389        assert_eq!(format!("{}", BrowserFamily::Firefox), "Firefox");
390        assert_eq!(format!("{}", BrowserFamily::Safari), "Safari");
391    }
392
393    #[test]
394    fn browser_family_display_ie_and_edge_legacy() {
395        assert_eq!(
396            format!("{}", BrowserFamily::InternetExplorer),
397            "Internet Explorer"
398        );
399        assert_eq!(format!("{}", BrowserFamily::EdgeLegacy), "Edge (Legacy)");
400    }
401
402    #[test]
403    fn artifact_kind_display() {
404        assert_eq!(format!("{}", ArtifactKind::History), "History");
405        assert_eq!(format!("{}", ArtifactKind::Cookies), "Cookies");
406        assert_eq!(format!("{}", ArtifactKind::Bookmarks), "Bookmarks");
407        assert_eq!(format!("{}", ArtifactKind::Autofill), "Autofill");
408        assert_eq!(format!("{}", ArtifactKind::Session), "Session");
409    }
410
411    #[test]
412    fn browser_event_with_attr() {
413        use serde_json::json;
414        let ev = BrowserEvent::new(
415            1_000_000,
416            BrowserFamily::Chromium,
417            ArtifactKind::History,
418            "/path/to/History",
419            "example.com",
420        )
421        .with_attr("url", json!("https://example.com"));
422        assert_eq!(ev.attrs["url"], json!("https://example.com"));
423        assert_eq!(ev.timestamp_ns, 1_000_000);
424    }
425
426    #[test]
427    fn artifact_kind_has_integrity_variant() {
428        let _ik = ArtifactKind::Integrity;
429        assert_eq!(format!("{}", ArtifactKind::Integrity), "Integrity");
430    }
431
432    #[test]
433    fn artifact_kind_has_carved_variant() {
434        let _c = ArtifactKind::Carved;
435        assert_eq!(format!("{}", ArtifactKind::Carved), "Carved");
436    }
437
438    #[test]
439    fn artifact_kind_has_memory_variant() {
440        let _m = ArtifactKind::Memory;
441        assert_eq!(format!("{}", ArtifactKind::Memory), "Memory");
442    }
443
444    #[test]
445    fn artifact_kind_has_preferences_variant() {
446        let _p = ArtifactKind::Preferences;
447        assert_eq!(format!("{}", ArtifactKind::Preferences), "Preferences");
448    }
449
450    #[test]
451    fn artifact_kind_has_local_storage_variant() {
452        let _ls = ArtifactKind::LocalStorage;
453        assert_eq!(format!("{}", ArtifactKind::LocalStorage), "LocalStorage");
454    }
455
456    #[test]
457    fn artifact_kind_has_permission_variant() {
458        let _p = ArtifactKind::Permission;
459        assert_eq!(format!("{}", ArtifactKind::Permission), "Permission");
460    }
461
462    #[test]
463    fn artifact_kind_has_credit_card_variant() {
464        let _c = ArtifactKind::CreditCard;
465        assert_eq!(format!("{}", ArtifactKind::CreditCard), "CreditCard");
466    }
467
468    #[test]
469    fn artifact_kind_has_auth_token_variant() {
470        let _t = ArtifactKind::AuthToken;
471        assert_eq!(format!("{}", ArtifactKind::AuthToken), "AuthToken");
472    }
473
474    #[test]
475    fn artifact_kind_has_recovered_domain_variant() {
476        let _r = ArtifactKind::RecoveredDomain;
477        assert_eq!(
478            format!("{}", ArtifactKind::RecoveredDomain),
479            "RecoveredDomain"
480        );
481    }
482
483    #[test]
484    fn artifact_kind_has_favicon_variant() {
485        let _f = ArtifactKind::Favicon;
486        assert_eq!(format!("{}", ArtifactKind::Favicon), "Favicon");
487    }
488
489    #[test]
490    fn artifact_kind_has_top_site_variant() {
491        let _t = ArtifactKind::TopSite;
492        assert_eq!(format!("{}", ArtifactKind::TopSite), "TopSite");
493    }
494
495    #[test]
496    fn artifact_kind_has_shortcut_variant() {
497        let _s = ArtifactKind::Shortcut;
498        assert_eq!(format!("{}", ArtifactKind::Shortcut), "Shortcut");
499    }
500
501    #[test]
502    fn artifact_kind_has_network_prediction_variant() {
503        let _n = ArtifactKind::NetworkPrediction;
504        assert_eq!(
505            format!("{}", ArtifactKind::NetworkPrediction),
506            "NetworkPrediction"
507        );
508    }
509
510    #[test]
511    fn artifact_kind_has_media_playback_variant() {
512        let _m = ArtifactKind::MediaPlayback;
513        assert_eq!(format!("{}", ArtifactKind::MediaPlayback), "MediaPlayback");
514    }
515
516    #[test]
517    fn forensic_meta_lookup_chrome_history() {
518        let meta = ForensicMeta::lookup("browser_chrome_history");
519        assert!(meta.is_some());
520        let meta = meta.unwrap();
521        assert_eq!(meta.artifact_id, "browser_chrome_history");
522        assert!(meta.evidence_strength.is_some());
523    }
524
525    #[test]
526    fn forensic_meta_lookup_unknown_returns_none() {
527        let meta = ForensicMeta::lookup("nonexistent_artifact_xyz");
528        assert!(meta.is_none());
529    }
530
531    #[test]
532    fn forensic_meta_all_browser_artifacts_have_profiles() {
533        let artifact_ids = [
534            "browser_chrome_history",
535            "browser_chrome_cookies",
536            "browser_chrome_downloads",
537            "browser_chrome_bookmarks",
538            "browser_chrome_extensions",
539            "browser_chrome_autofill",
540            "browser_chrome_cache",
541            "browser_chrome_session",
542            "browser_firefox_history",
543            "browser_firefox_cookies",
544            "browser_firefox_downloads",
545            "browser_safari_history",
546        ];
547
548        for id in &artifact_ids {
549            let meta = ForensicMeta::lookup(id);
550            assert!(
551                meta.is_some(),
552                "ForensicMeta::lookup({id}) should return Some"
553            );
554        }
555    }
556
557    #[test]
558    fn forensic_meta_evidence_strength_is_populated() {
559        let meta = ForensicMeta::lookup("browser_chrome_downloads").expect("should exist");
560        assert!(
561            meta.evidence_strength.is_some(),
562            "evidence_strength should be Some"
563        );
564        // Downloads are Strong evidence
565        let strength = meta
566            .evidence_strength
567            .as_deref()
568            .expect("should have value");
569        assert!(
570            strength.contains("Strong"),
571            "Downloads should be Strong evidence, got: {strength}"
572        );
573    }
574}