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 sqlite;
13pub mod test_utils;
14pub mod timestamp;
15
16use std::collections::HashMap;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21pub use forensicnomicon::evidence::EvidenceStrength;
22
23/// Browser engine family.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub enum BrowserFamily {
26    Chromium,
27    Firefox,
28    Safari,
29}
30
31impl std::fmt::Display for BrowserFamily {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Chromium => write!(f, "Chromium"),
35            Self::Firefox => write!(f, "Firefox"),
36            Self::Safari => write!(f, "Safari"),
37        }
38    }
39}
40
41/// Kind of browser artifact.
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum ArtifactKind {
44    History,
45    Cookies,
46    Downloads,
47    Extensions,
48    LoginData,
49    Cache,
50    Bookmarks,
51    Autofill,
52    Session,
53    Integrity,
54    Carved,
55    Memory,
56}
57
58impl std::fmt::Display for ArtifactKind {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::History => write!(f, "History"),
62            Self::Cookies => write!(f, "Cookies"),
63            Self::Downloads => write!(f, "Downloads"),
64            Self::Extensions => write!(f, "Extensions"),
65            Self::LoginData => write!(f, "LoginData"),
66            Self::Cache => write!(f, "Cache"),
67            Self::Bookmarks => write!(f, "Bookmarks"),
68            Self::Autofill => write!(f, "Autofill"),
69            Self::Session => write!(f, "Session"),
70            Self::Integrity => write!(f, "Integrity"),
71            Self::Carved => write!(f, "Carved"),
72            Self::Memory => write!(f, "Memory"),
73        }
74    }
75}
76
77/// A single browser forensic event.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct BrowserEvent {
80    pub timestamp_ns: i64,
81    pub browser: BrowserFamily,
82    pub artifact: ArtifactKind,
83    pub source: String,
84    pub description: String,
85    pub attrs: HashMap<String, serde_json::Value>,
86}
87
88impl BrowserEvent {
89    #[must_use]
90    pub fn new(
91        timestamp_ns: i64,
92        browser: BrowserFamily,
93        artifact: ArtifactKind,
94        source: impl Into<String>,
95        description: impl Into<String>,
96    ) -> Self {
97        Self {
98            timestamp_ns,
99            browser,
100            artifact,
101            source: source.into(),
102            description: description.into(),
103            attrs: HashMap::new(),
104        }
105    }
106
107    #[must_use]
108    pub fn with_attr(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
109        self.attrs.insert(key.into(), value);
110        self
111    }
112}
113
114/// Forensic metadata from forensicnomicon for a specific browser artifact.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ForensicMeta {
117    pub artifact_id: String,
118    pub evidence_strength: Option<String>,
119    pub volatility: Option<String>,
120    pub caveats: Vec<String>,
121}
122
123impl ForensicMeta {
124    /// Look up forensic metadata for the given artifact ID.
125    /// Returns `None` if the artifact is not in forensicnomicon's catalog.
126    #[must_use]
127    pub fn lookup(artifact_id: &str) -> Option<Self> {
128        let desc = forensicnomicon::evidence::evidence_for(artifact_id)?;
129        Some(Self {
130            artifact_id: artifact_id.to_string(),
131            evidence_strength: desc.evidence_strength.map(|s| format!("{s:?}")),
132            volatility: desc.volatility.map(|v| format!("{v:?}")),
133            caveats: desc
134                .evidence_caveats
135                .iter()
136                .map(|c| (*c).to_string())
137                .collect(),
138        })
139    }
140}
141
142/// Detect the browser family from a file path.
143///
144/// Returns `None` if the path does not match a known browser artifact.
145#[must_use]
146pub fn detect_browser(path: &Path) -> Option<BrowserFamily> {
147    let name = path.file_name()?.to_string_lossy().to_lowercase();
148    let path_str = path.to_string_lossy().to_lowercase();
149
150    // Safari
151    if path_str.contains("safari") {
152        let safari_files = [
153            "history.db",
154            "cookies.db",
155            "downloads.plist",
156            "bookmarks.plist",
157        ];
158        if safari_files.contains(&name.as_str()) {
159            return Some(BrowserFamily::Safari);
160        }
161    }
162
163    // Chromium family
164    let chromium_vendors = [
165        "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "arc",
166    ];
167    let is_chromium_path = chromium_vendors.iter().any(|b| path_str.contains(b));
168    let chromium_files = ["history", "cookies", "login data", "web data", "bookmarks"];
169    if chromium_files.contains(&name.as_str()) && is_chromium_path {
170        return Some(BrowserFamily::Chromium);
171    }
172
173    // Firefox family
174    if name == "places.sqlite" || name == "formhistory.sqlite" {
175        return Some(BrowserFamily::Firefox);
176    }
177    let firefox_files = [
178        "cookies.sqlite",
179        "logins.json",
180        "extensions.json",
181        "sessionstore.jsonlz4",
182    ];
183    if firefox_files.contains(&name.as_str())
184        && (path_str.contains("firefox") || path_str.contains("mozilla"))
185    {
186        return Some(BrowserFamily::Firefox);
187    }
188
189    None
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn detect_chrome_history() {
198        let p = Path::new("/home/user/.config/google-chrome/Default/History");
199        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
200    }
201
202    #[test]
203    fn detect_edge_history() {
204        let p = Path::new("/home/user/.config/microsoft-edge/Default/History");
205        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
206    }
207
208    #[test]
209    fn detect_firefox_places() {
210        let p = Path::new("/home/user/.mozilla/firefox/abc.default/places.sqlite");
211        assert_eq!(detect_browser(p), Some(BrowserFamily::Firefox));
212    }
213
214    #[test]
215    fn detect_firefox_cookies() {
216        let p = Path::new("/home/user/.mozilla/firefox/abc.default/cookies.sqlite");
217        assert_eq!(detect_browser(p), Some(BrowserFamily::Firefox));
218    }
219
220    #[test]
221    fn detect_unknown_returns_none() {
222        assert_eq!(detect_browser(Path::new("/tmp/foo.db")), None);
223    }
224
225    #[test]
226    fn browser_family_has_safari_variant() {
227        let _safari = BrowserFamily::Safari;
228    }
229
230    #[test]
231    fn artifact_kind_has_bookmarks() {
232        let _bk = ArtifactKind::Bookmarks;
233    }
234
235    #[test]
236    fn artifact_kind_has_autofill() {
237        let _af = ArtifactKind::Autofill;
238    }
239
240    #[test]
241    fn artifact_kind_has_session() {
242        let _s = ArtifactKind::Session;
243    }
244
245    #[test]
246    fn detect_safari_history_db() {
247        let p = Path::new("/Users/test/Library/Safari/History.db");
248        assert_eq!(detect_browser(p), Some(BrowserFamily::Safari));
249    }
250
251    #[test]
252    fn detect_brave_history() {
253        let p = Path::new(
254            "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser/Default/History",
255        );
256        assert_eq!(detect_browser(p), Some(BrowserFamily::Chromium));
257    }
258
259    #[test]
260    fn browser_family_display() {
261        assert_eq!(format!("{}", BrowserFamily::Chromium), "Chromium");
262        assert_eq!(format!("{}", BrowserFamily::Firefox), "Firefox");
263        assert_eq!(format!("{}", BrowserFamily::Safari), "Safari");
264    }
265
266    #[test]
267    fn artifact_kind_display() {
268        assert_eq!(format!("{}", ArtifactKind::History), "History");
269        assert_eq!(format!("{}", ArtifactKind::Cookies), "Cookies");
270        assert_eq!(format!("{}", ArtifactKind::Bookmarks), "Bookmarks");
271        assert_eq!(format!("{}", ArtifactKind::Autofill), "Autofill");
272        assert_eq!(format!("{}", ArtifactKind::Session), "Session");
273    }
274
275    #[test]
276    fn browser_event_with_attr() {
277        use serde_json::json;
278        let ev = BrowserEvent::new(
279            1_000_000,
280            BrowserFamily::Chromium,
281            ArtifactKind::History,
282            "/path/to/History",
283            "example.com",
284        )
285        .with_attr("url", json!("https://example.com"));
286        assert_eq!(ev.attrs["url"], json!("https://example.com"));
287        assert_eq!(ev.timestamp_ns, 1_000_000);
288    }
289
290    #[test]
291    fn artifact_kind_has_integrity_variant() {
292        let _ik = ArtifactKind::Integrity;
293        assert_eq!(format!("{}", ArtifactKind::Integrity), "Integrity");
294    }
295
296    #[test]
297    fn artifact_kind_has_carved_variant() {
298        let _c = ArtifactKind::Carved;
299        assert_eq!(format!("{}", ArtifactKind::Carved), "Carved");
300    }
301
302    #[test]
303    fn artifact_kind_has_memory_variant() {
304        let _m = ArtifactKind::Memory;
305        assert_eq!(format!("{}", ArtifactKind::Memory), "Memory");
306    }
307
308    #[test]
309    fn forensic_meta_lookup_chrome_history() {
310        let meta = ForensicMeta::lookup("browser_chrome_history");
311        assert!(meta.is_some());
312        let meta = meta.unwrap();
313        assert_eq!(meta.artifact_id, "browser_chrome_history");
314        assert!(meta.evidence_strength.is_some());
315    }
316
317    #[test]
318    fn forensic_meta_lookup_unknown_returns_none() {
319        let meta = ForensicMeta::lookup("nonexistent_artifact_xyz");
320        assert!(meta.is_none());
321    }
322
323    #[test]
324    fn forensic_meta_all_browser_artifacts_have_profiles() {
325        let artifact_ids = [
326            "browser_chrome_history",
327            "browser_chrome_cookies",
328            "browser_chrome_downloads",
329            "browser_chrome_bookmarks",
330            "browser_chrome_extensions",
331            "browser_chrome_autofill",
332            "browser_chrome_cache",
333            "browser_chrome_session",
334            "browser_firefox_history",
335            "browser_firefox_cookies",
336            "browser_firefox_downloads",
337            "browser_safari_history",
338        ];
339
340        for id in &artifact_ids {
341            let meta = ForensicMeta::lookup(id);
342            assert!(
343                meta.is_some(),
344                "ForensicMeta::lookup({id}) should return Some"
345            );
346        }
347    }
348
349    #[test]
350    fn forensic_meta_evidence_strength_is_populated() {
351        let meta = ForensicMeta::lookup("browser_chrome_downloads").expect("should exist");
352        assert!(
353            meta.evidence_strength.is_some(),
354            "evidence_strength should be Some"
355        );
356        // Downloads are Strong evidence
357        let strength = meta
358            .evidence_strength
359            .as_deref()
360            .expect("should have value");
361        assert!(
362            strength.contains("Strong"),
363            "Downloads should be Strong evidence, got: {strength}"
364        );
365    }
366}