From c49b4265b13ea99ff88bb5d9e1f899baccfff367 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Mon, 17 Aug 2026 14:12:50 +0200 Subject: [PATCH] Collect browser history with existing root access --- README.md | 17 ++- acquisition/acquisition.go | 22 +++- acquisition/streaming_buffer.go | 45 +++++++- acquisition/streaming_buffer_test.go | 29 ++++++ adb/adb.go | 32 ++++++ main.go | 11 +- main_options_test.go | 30 +++--- modules/browser_history.go | 148 +++++++++++++++++++++++++++ modules/browser_history_test.go | 113 ++++++++++++++++++++ modules/modules.go | 1 + modules/options.go | 4 + modules/options_test.go | 20 +++- 12 files changed, 447 insertions(+), 25 deletions(-) create mode 100644 modules/browser_history.go create mode 100644 modules/browser_history_test.go diff --git a/README.md b/README.md index 20d7684..462068f 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,20 @@ Selecting `Yes` adds MD5, SHA-1, SHA-256 and SHA-512 hashes to the entries in device and can take a long time or cause the collector to stop on devices with limited resources. The default `No` option only collects file metadata. +### Browser history + +AndroidQF can optionally collect Chromium `History` databases from supported +browsers when the device already provides working root access through `su`. +AndroidQF does not root the device, stop browser processes, or copy the files +through shared storage. The default `No` option skips this collection. + +When enabled, AndroidQF streams each database and any present `-wal` and `-shm` +sidecars directly to host-side staging before adding them under +`browser_history/` in the acquisition. A `browser_history/manifest.json` file +records the browser, package, profile, original device path, and archive path. +The currently supported packages are Chrome, Brave, Microsoft Edge, and +Samsung Internet. + ### Unattended acquisitions Every prompt can be answered ahead of time with a command-line flag. A flag that is not passed keeps prompting interactively as before. @@ -226,11 +240,12 @@ Every prompt can be answered ahead of time with a command-line flag. A flag that | `-remove-trusted` / `-r` | `yes`, `no` | [Removing apps signed with a trusted certificate](#removing-apps-signed-with-a-trusted-certificate), ignored with `-download none` | | `-intrusion-logs` / `-i` | `yes`, `no` | [Intrusion Logs](#intrusion-logs) | | `-hash-files` / `-H` | `yes`, `no` | [Hashing files on the device](#hashing-files-on-the-device) | +| `-browser-history` | `yes`, `no` | [Browser history](#browser-history) | With `-non-interactive` (`-n`), androidqf never prompts: it fails before the acquisition starts if one of the flags above is missing, fails if multiple devices are attached and no `-serial` is given, and skips the final "Press Enter to finish". A fully unattended run looks like this: ```bash -androidqf -serial -backup none -download all -remove-trusted no -intrusion-logs no -hash-files no -non-interactive +androidqf -serial -backup none -download all -remove-trusted no -intrusion-logs no -hash-files no -browser-history no -non-interactive ``` > [!NOTE] diff --git a/acquisition/acquisition.go b/acquisition/acquisition.go index e3a43dd..d9df627 100644 --- a/acquisition/acquisition.go +++ b/acquisition/acquisition.go @@ -171,12 +171,26 @@ func (a *Acquisition) Complete() error { // entry. Encrypted acquisitions use encrypted temporary storage so plaintext is // never staged on disk. func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error { + return a.pullToZipStaged(remotePath, zipPath, false) +} + +// PullRootToZipStaged validates a complete root-readable device pull before +// creating its ZIP entry. +func (a *Acquisition) PullRootToZipStaged(remotePath, zipPath string) error { + return a.pullToZipStaged(remotePath, zipPath, true) +} + +func (a *Acquisition) pullToZipStaged(remotePath, zipPath string, root bool) error { if err := a.validateStreamingMode(); err != nil { return err } if a.ZipWriter.IsEncrypted() { - staged, err := a.StreamingPuller.PullToEncryptedTempFile(remotePath) + pull := a.StreamingPuller.PullToEncryptedTempFile + if root { + pull = a.StreamingPuller.PullRootToEncryptedTempFile + } + staged, err := pull(remotePath) if err != nil { return err } @@ -190,7 +204,11 @@ func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error { return a.ZipWriter.CreateFileFromReader(zipPath, reader) } - tempPath, err := a.StreamingPuller.PullToTempFile(remotePath) + pull := a.StreamingPuller.PullToTempFile + if root { + pull = a.StreamingPuller.PullRootToTempFile + } + tempPath, err := pull(remotePath) if err != nil { return err } diff --git a/acquisition/streaming_buffer.go b/acquisition/streaming_buffer.go index 3dace76..100ac39 100644 --- a/acquisition/streaming_buffer.go +++ b/acquisition/streaming_buffer.go @@ -166,6 +166,17 @@ func (sp *StreamingPuller) PullToBuffer(remotePath string) (*StreamingBuffer, er // PullToWriter pulls a file from device and streams it directly to a writer func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) error { + return sp.pullToWriter(remotePath, writer, false) +} + +// PullRootToWriter pulls a file that is only readable as root and streams it +// directly to a writer. It requires an already-functional su binary and never +// attempts to alter the device's root state. +func (sp *StreamingPuller) PullRootToWriter(remotePath string, writer io.Writer) error { + return sp.pullToWriter(remotePath, writer, true) +} + +func (sp *StreamingPuller) pullToWriter(remotePath string, writer io.Writer, root bool) error { if remotePath == "" { return fmt.Errorf("remote path cannot be empty") } @@ -174,6 +185,9 @@ func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) err } args := []string{"exec-out", "cat", remotePath} + if root { + args = []string{"exec-out", "su", "-c", "cat -- " + shellQuote(remotePath)} + } if sp.serial != "" { args = append([]string{"-s", sp.serial}, args...) } @@ -189,16 +203,34 @@ func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) err return nil } +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + // PullToTempFile pulls a file from the device into a temporary file and // returns its path. The caller is responsible for removing the file. func (sp *StreamingPuller) PullToTempFile(remotePath string) (string, error) { + return sp.pullToTempFile(remotePath, false) +} + +// PullRootToTempFile stages a root-readable device file in a host temporary +// file. The caller is responsible for removing the returned path. +func (sp *StreamingPuller) PullRootToTempFile(remotePath string) (string, error) { + return sp.pullToTempFile(remotePath, true) +} + +func (sp *StreamingPuller) pullToTempFile(remotePath string, root bool) (string, error) { tempFile, err := os.CreateTemp("", "androidqf-pull-*") if err != nil { return "", fmt.Errorf("failed to create temporary file: %w", err) } tempPath := tempFile.Name() - if err := sp.PullToWriter(remotePath, tempFile); err != nil { + pull := sp.PullToWriter + if root { + pull = sp.PullRootToWriter + } + if err := pull(remotePath, tempFile); err != nil { _ = tempFile.Close() _ = os.Remove(tempPath) return "", fmt.Errorf("failed to pull to temporary file: %w", err) @@ -223,6 +255,17 @@ func (sp *StreamingPuller) PullToEncryptedTempFile(remotePath string) (*Encrypte }) } +// PullRootToEncryptedTempFile stages a root-readable device file encrypted on +// the host. Plaintext is never written to host storage. +func (sp *StreamingPuller) PullRootToEncryptedTempFile(remotePath string) (*EncryptedTempFile, error) { + if remotePath == "" { + return nil, fmt.Errorf("remote path cannot be empty") + } + return createEncryptedTempFile(func(writer io.Writer) error { + return sp.PullRootToWriter(remotePath, writer) + }) +} + func createEncryptedTempFile(writePlaintext func(io.Writer) error) (*EncryptedTempFile, error) { if writePlaintext == nil { return nil, fmt.Errorf("plaintext writer cannot be nil") diff --git a/acquisition/streaming_buffer_test.go b/acquisition/streaming_buffer_test.go index 8bf6840..540fa15 100644 --- a/acquisition/streaming_buffer_test.go +++ b/acquisition/streaming_buffer_test.go @@ -188,3 +188,32 @@ func TestPullToZipStagedDoesNotCreateEntryForFailedPull(t *testing.T) { } } } + +func TestPullRootToWriterUsesSuAndQuotesPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-specific") + } + + fakeADB := filepath.Join(t.TempDir(), "adb") + script := `#!/bin/sh +[ "$1" = "-s" ] || exit 2 +[ "$2" = "serial-1" ] || exit 2 +[ "$3" = "exec-out" ] || exit 2 +[ "$4" = "su" ] || exit 2 +[ "$5" = "-c" ] || exit 2 +[ "$6" = "cat -- '/data/data/example'\"'\"'s/History'" ] || exit 2 +printf 'history data' +` + if err := os.WriteFile(fakeADB, []byte(script), 0o700); err != nil { + t.Fatalf("WriteFile(fake adb) error = %v", err) + } + + var output bytes.Buffer + puller := NewStreamingPuller(fakeADB, "serial-1", 1) + if err := puller.PullRootToWriter("/data/data/example's/History", &output); err != nil { + t.Fatalf("PullRootToWriter() error = %v", err) + } + if got := output.String(); got != "history data" { + t.Fatalf("output = %q, want history data", got) + } +} diff --git a/adb/adb.go b/adb/adb.go index 3bdcf5f..e0dced2 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -190,6 +190,38 @@ func (a *ADB) Shell(cmd ...string) (string, error) { return strings.TrimSpace(string(out)), nil } +// RootShell executes a command through an already-functional su binary. It +// does not attempt to enable or install root access. +func (a *ADB) RootShell(command string) (string, error) { + if strings.TrimSpace(command) == "" { + return "", fmt.Errorf("root shell command cannot be empty") + } + return a.Shell("su", "-c", command) +} + +// HasRoot reports whether su can execute commands as UID 0. +func (a *ADB) HasRoot() bool { + out, err := a.RootShell("id -u") + return err == nil && strings.TrimSpace(out) == "0" +} + +// FileExistsAsRoot checks a fixed device path without exposing it to shell +// expansion. +func (a *ADB) FileExistsAsRoot(devicePath string) (bool, error) { + if devicePath == "" { + return false, fmt.Errorf("device path cannot be empty") + } + out, err := a.RootShell("if [ -f " + shellQuote(devicePath) + " ]; then printf 1; else printf 0; fi") + if err != nil { + return false, err + } + return strings.TrimSpace(out) == "1", nil +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + // Pull downloads a file from the device to a local path. func (a *ADB) Pull(remotePath, localPath string) (string, error) { out, err := a.Exec("pull", remotePath, localPath) diff --git a/main.go b/main.go index c9bc600..d7d01ee 100644 --- a/main.go +++ b/main.go @@ -122,7 +122,7 @@ func errorOnDeviceSelection([]deviceMenuItem) (string, error) { return "", fmt.Errorf("multiple devices detected, use -serial to select one") } -func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, intrusionLogs, hashFiles, moduleFilter string) (*modules.Options, error) { +func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, intrusionLogs, hashFiles, browserHistory, moduleFilter string) (*modules.Options, error) { opts := &modules.Options{Fast: fast, NonInteractive: nonInteractive} var err error if backup != "" { @@ -150,6 +150,11 @@ func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, in return nil, err } } + if browserHistory != "" { + if opts.BrowserHistory, err = modules.ParseBrowserHistoryOption(browserHistory); err != nil { + return nil, err + } + } if err = modules.ValidateNonInteractive(opts, moduleFilter); err != nil { return nil, err } @@ -171,6 +176,7 @@ func main() { var removeTrustedFlag string var intrusionLogsFlag string var hashFilesFlag string + var browserHistoryFlag string var nonInteractive bool // Command line options @@ -198,6 +204,7 @@ func main() { flag.StringVar(&intrusionLogsFlag, "i", "", "Answer the Intrusion Logs prompt: yes or no (yes still requires taps on the device to download new logs)") flag.StringVar(&hashFilesFlag, "hash-files", "", "Answer the on-device file hashing prompt: yes or no (resource-intensive)") flag.StringVar(&hashFilesFlag, "H", "", "Answer the on-device file hashing prompt: yes or no (resource-intensive)") + flag.StringVar(&browserHistoryFlag, "browser-history", "", "Collect supported browser History databases when existing root access is available: yes or no") flag.BoolVar(&nonInteractive, "non-interactive", false, "Never prompt: fail if a prompt would be reached without its flag and skip the final 'Press Enter'") flag.BoolVar(&nonInteractive, "n", false, "Never prompt: fail if a prompt would be reached without its flag and skip the final 'Press Enter'") flag.BoolVar(&version_flag, "version", false, "Show version") @@ -221,7 +228,7 @@ func main() { os.Exit(0) } - opts, err := buildOptions(fast, nonInteractive, backupFlag, downloadFlag, removeTrustedFlag, intrusionLogsFlag, hashFilesFlag, module) + opts, err := buildOptions(fast, nonInteractive, backupFlag, downloadFlag, removeTrustedFlag, intrusionLogsFlag, hashFilesFlag, browserHistoryFlag, module) if err != nil { log.Fatal(err) } diff --git a/main_options_test.go b/main_options_test.go index 3f4deaa..1192479 100644 --- a/main_options_test.go +++ b/main_options_test.go @@ -8,7 +8,7 @@ import ( ) func TestBuildOptionsNoFlagsKeepsInteractiveDefaults(t *testing.T) { - opts, err := buildOptions(false, false, "", "", "", "", "", "") + opts, err := buildOptions(false, false, "", "", "", "", "", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -19,19 +19,20 @@ func TestBuildOptionsNoFlagsKeepsInteractiveDefaults(t *testing.T) { func TestBuildOptionsInvalidValueFails(t *testing.T) { tests := []struct { - name string - backup, download, removeTrusted, intrusionLogs, hashFiles string - wantErr string + name string + backup, download, removeTrusted, intrusionLogs, hashFiles, browserHistory string + wantErr string }{ - {"backup", "maybe", "", "", "", "", "invalid -backup value"}, - {"download", "", "some", "", "", "", "invalid -download value"}, - {"remove-trusted", "", "", "nope", "", "", "invalid -remove-trusted value"}, - {"intrusion-logs", "", "", "", "never", "", "invalid -intrusion-logs value"}, - {"hash-files", "", "", "", "", "maybe", "invalid -hash-files value"}, + {"backup", "maybe", "", "", "", "", "", "invalid -backup value"}, + {"download", "", "some", "", "", "", "", "invalid -download value"}, + {"remove-trusted", "", "", "nope", "", "", "", "invalid -remove-trusted value"}, + {"intrusion-logs", "", "", "", "never", "", "", "invalid -intrusion-logs value"}, + {"hash-files", "", "", "", "", "maybe", "", "invalid -hash-files value"}, + {"browser-history", "", "", "", "", "", "maybe", "invalid -browser-history value"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := buildOptions(false, false, tt.backup, tt.download, tt.removeTrusted, tt.intrusionLogs, tt.hashFiles, "") + _, err := buildOptions(false, false, tt.backup, tt.download, tt.removeTrusted, tt.intrusionLogs, tt.hashFiles, tt.browserHistory, "") if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want containing %q", err, tt.wantErr) } @@ -40,28 +41,28 @@ func TestBuildOptionsInvalidValueFails(t *testing.T) { } func TestBuildOptionsNonInteractiveUnknownModuleFails(t *testing.T) { - _, err := buildOptions(false, true, "", "", "", "", "", "typo") + _, err := buildOptions(false, true, "", "", "", "", "", "", "typo") if err == nil || !strings.Contains(err.Error(), "unknown -module value") { t.Fatalf("err = %v, want unknown -module error", err) } } func TestBuildOptionsNonInteractiveMissingFlagsFails(t *testing.T) { - _, err := buildOptions(false, true, "", "", "", "", "", "") + _, err := buildOptions(false, true, "", "", "", "", "", "", "") if err == nil || !strings.Contains(err.Error(), "-non-interactive requires") { t.Fatalf("err = %v, want missing flags error", err) } } func TestBuildOptionsNonInteractiveModuleFilter(t *testing.T) { - _, err := buildOptions(false, true, "none", "", "", "", "", "backup") + _, err := buildOptions(false, true, "none", "", "", "", "", "", "backup") if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestBuildOptionsFullInvocation(t *testing.T) { - opts, err := buildOptions(true, true, "sms", "all", "no", "no", "yes", "") + opts, err := buildOptions(true, true, "sms", "all", "no", "no", "yes", "yes", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -78,6 +79,7 @@ func TestBuildOptionsFullInvocation(t *testing.T) { {opts.RemoveTrusted, modules.ParseRemoveTrustedOption, "no"}, {opts.IntrusionLogs, modules.ParseIntrusionLogsOption, "no"}, {opts.HashFiles, modules.ParseHashFilesOption, "yes"}, + {opts.BrowserHistory, modules.ParseBrowserHistoryOption, "yes"}, } { want, err := check.parse(check.token) if err != nil { diff --git a/modules/browser_history.go b/modules/browser_history.go new file mode 100644 index 0000000..07977e9 --- /dev/null +++ b/modules/browser_history.go @@ -0,0 +1,148 @@ +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2026 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package modules + +import ( + "fmt" + "path" + "strings" + "time" + + "github.com/manifoldco/promptui" + "github.com/mvt-project/androidqf/acquisition" + "github.com/mvt-project/androidqf/adb" + "github.com/mvt-project/androidqf/log" +) + +const ( + acquireBrowserHistory = "Yes" + skipBrowserHistory = "No" +) + +type BrowserHistory struct{} + +type browserHistoryTarget struct { + Browser string + Package string + Profile string + DevicePath string +} + +type browserHistorySidecar struct { + DevicePath string `json:"device_path"` + ArchivePath string `json:"archive_path"` +} + +type browserHistoryDatabase struct { + Browser string `json:"browser"` + Package string `json:"package"` + Profile string `json:"profile"` + DevicePath string `json:"device_path"` + ArchivePath string `json:"archive_path"` + Sidecars []browserHistorySidecar `json:"sidecars"` +} + +type browserHistoryManifest struct { + SchemaVersion int `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Status string `json:"status"` + AcquisitionMethod string `json:"acquisition_method"` + Databases []browserHistoryDatabase `json:"databases"` +} + +// These locations are deliberately limited to paths backed by public parser +// fixtures or the historical MVT implementation. Additions require equivalent +// evidence; package-name guesses are not sufficient. +var browserHistoryTargets = []browserHistoryTarget{ + {"Chrome", "com.android.chrome", "Default", "/data/data/com.android.chrome/app_chrome/Default/History"}, + {"Brave", "com.brave.browser", "Default", "/data/data/com.brave.browser/app_chrome/Default/History"}, + {"Microsoft Edge", "com.microsoft.emmx", "Default", "/data/data/com.microsoft.emmx/app_chrome/Default/History"}, + {"Samsung Internet", "com.sec.android.app.sbrowser", "Default", "/data/data/com.sec.android.app.sbrowser/app_sbrowser/Default/History"}, +} + +func NewBrowserHistory() *BrowserHistory { return &BrowserHistory{} } + +func (m *BrowserHistory) Name() string { return "browser_history" } + +func ParseBrowserHistoryOption(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "yes": + return acquireBrowserHistory, nil + case "no": + return skipBrowserHistory, nil + } + return "", fmt.Errorf("invalid -browser-history value %q (valid values: yes, no)", value) +} + +func (m *BrowserHistory) Run(acq *acquisition.Acquisition, opts *Options) error { + selection, err := resolveOption(opts, opts.BrowserHistory, "-browser-history (yes, no)", func() (string, error) { + log.Info("Would you like to collect supported browser history databases? This requires existing root access.") + prompt := promptui.Select{Label: "Browser history", Items: []string{skipBrowserHistory, acquireBrowserHistory}} + _, selection, err := prompt.Run() + return selection, err + }) + if err != nil { + return fmt.Errorf("failed to make selection for browser history: %w", err) + } + if selection == skipBrowserHistory { + log.Info("Skipping browser history extraction...") + return nil + } + + manifest := browserHistoryManifest{ + SchemaVersion: 1, + GeneratedAt: time.Now().UTC(), + Status: "no_databases", + AcquisitionMethod: "adb exec-out su -c cat", + Databases: []browserHistoryDatabase{}, + } + if adb.Client == nil || !adb.Client.HasRoot() { + manifest.Status = "root_unavailable" + log.Warning("Browser history collection requires an already-functional su binary; no rooting was attempted.") + return saveDataToAcquisition(acq, "browser_history/manifest.json", &manifest) + } + + for _, target := range browserHistoryTargets { + exists, err := adb.Client.FileExistsAsRoot(target.DevicePath) + if err != nil { + log.Warningf("Unable to check %s browser history: %v", target.Browser, err) + continue + } + if !exists { + continue + } + + archivePath := path.Join("browser_history", target.Package, target.Profile, "History") + if err := acq.PullRootToZipStaged(target.DevicePath, archivePath); err != nil { + log.Warningf("Unable to collect %s browser history: %v", target.Browser, err) + continue + } + database := browserHistoryDatabase{ + Browser: target.Browser, Package: target.Package, Profile: target.Profile, + DevicePath: target.DevicePath, ArchivePath: archivePath, + Sidecars: []browserHistorySidecar{}, + } + for _, suffix := range []string{"-wal", "-shm"} { + deviceSidecar := target.DevicePath + suffix + exists, err := adb.Client.FileExistsAsRoot(deviceSidecar) + if err != nil || !exists { + continue + } + archiveSidecar := archivePath + suffix + if err := acq.PullRootToZipStaged(deviceSidecar, archiveSidecar); err != nil { + log.Warningf("Unable to collect %s sidecar %s: %v", target.Browser, suffix, err) + continue + } + database.Sidecars = append(database.Sidecars, browserHistorySidecar{DevicePath: deviceSidecar, ArchivePath: archiveSidecar}) + } + manifest.Databases = append(manifest.Databases, database) + } + + if len(manifest.Databases) > 0 { + manifest.Status = "collected" + } + return saveDataToAcquisition(acq, "browser_history/manifest.json", &manifest) +} diff --git a/modules/browser_history_test.go b/modules/browser_history_test.go new file mode 100644 index 0000000..b10567f --- /dev/null +++ b/modules/browser_history_test.go @@ -0,0 +1,113 @@ +package modules + +import ( + "archive/zip" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/mvt-project/androidqf/acquisition" + "github.com/mvt-project/androidqf/adb" +) + +func TestBrowserHistoryCollectsDatabaseAndManifest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-specific") + } + + fakeADB := filepath.Join(t.TempDir(), "adb") + script := `#!/bin/sh +case "$*" in + *"id -u"*) printf '0' ;; + *"if [ -f"*"com.android.chrome"*"History'"* ) printf '1' ;; + *"if [ -f"*) printf '0' ;; + *"cat --"*"com.android.chrome"*"History'"*) printf 'sqlite history' ;; + *) exit 1 ;; +esac +` + if err := os.WriteFile(fakeADB, []byte(script), 0o700); err != nil { + t.Fatalf("WriteFile(fake adb) error = %v", err) + } + + oldClient := adb.Client + adb.Client = &adb.ADB{ExePath: fakeADB} + defer func() { adb.Client = oldClient }() + + writer, err := acquisition.NewStreamingZipWriter("browser-history-test", t.TempDir()) + if err != nil { + t.Fatalf("NewStreamingZipWriter() error = %v", err) + } + acq := &acquisition.Acquisition{ + ZipWriter: writer, StreamingMode: true, + StreamingPuller: acquisition.NewStreamingPuller(fakeADB, "", 1), + } + if err := NewBrowserHistory().Run(acq, &Options{BrowserHistory: acquireBrowserHistory}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + archive, err := zip.OpenReader(writer.GetOutputPath()) + if err != nil { + t.Fatalf("OpenReader() error = %v", err) + } + defer archive.Close() + + entries := make(map[string][]byte) + for _, file := range archive.File { + reader, err := file.Open() + if err != nil { + t.Fatalf("Open(%s) error = %v", file.Name, err) + } + entries[file.Name], err = io.ReadAll(reader) + reader.Close() + if err != nil { + t.Fatalf("ReadAll(%s) error = %v", file.Name, err) + } + } + + dbPath := "browser_history/com.android.chrome/Default/History" + if got := string(entries[dbPath]); got != "sqlite history" { + t.Fatalf("database = %q, want sqlite history", got) + } + var manifest browserHistoryManifest + if err := json.Unmarshal(entries["browser_history/manifest.json"], &manifest); err != nil { + t.Fatalf("Unmarshal(manifest) error = %v", err) + } + if manifest.Status != "collected" || len(manifest.Databases) != 1 { + t.Fatalf("manifest = %+v, want one collected database", manifest) + } + if manifest.Databases[0].ArchivePath != dbPath { + t.Fatalf("archive path = %q, want %q", manifest.Databases[0].ArchivePath, dbPath) + } +} + +func TestBrowserHistoryRecordsUnavailableRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-specific") + } + + fakeADB := filepath.Join(t.TempDir(), "adb") + if err := os.WriteFile(fakeADB, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { + t.Fatalf("WriteFile(fake adb) error = %v", err) + } + oldClient := adb.Client + adb.Client = &adb.ADB{ExePath: fakeADB} + defer func() { adb.Client = oldClient }() + + writer, err := acquisition.NewStreamingZipWriter("browser-history-no-root", t.TempDir()) + if err != nil { + t.Fatalf("NewStreamingZipWriter() error = %v", err) + } + acq := &acquisition.Acquisition{ZipWriter: writer, StreamingMode: true, StreamingPuller: acquisition.NewStreamingPuller(fakeADB, "", 1)} + if err := NewBrowserHistory().Run(acq, &Options{BrowserHistory: acquireBrowserHistory}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } +} diff --git a/modules/modules.go b/modules/modules.go index 85b6171..ea864b6 100644 --- a/modules/modules.go +++ b/modules/modules.go @@ -27,6 +27,7 @@ func List() []Module { NewServices(), NewBugreport(), NewFiles(), + NewBrowserHistory(), NewSettings(), NewSELinux(), NewEnvironment(), diff --git a/modules/options.go b/modules/options.go index bddc7f4..8ca604d 100644 --- a/modules/options.go +++ b/modules/options.go @@ -21,6 +21,7 @@ type Options struct { RemoveTrusted string IntrusionLogs string HashFiles string + BrowserHistory string } func ModuleEnabled(name, filter string) bool { @@ -62,6 +63,9 @@ func ValidateNonInteractive(opts *Options, moduleFilter string) error { if ModuleEnabled(NewFiles().Name(), moduleFilter) && opts.HashFiles == "" { missing = append(missing, "-hash-files (yes, no)") } + if ModuleEnabled(NewBrowserHistory().Name(), moduleFilter) && opts.BrowserHistory == "" { + missing = append(missing, "-browser-history (yes, no)") + } if len(missing) == 0 { return nil diff --git a/modules/options_test.go b/modules/options_test.go index 403044f..585fd3f 100644 --- a/modules/options_test.go +++ b/modules/options_test.go @@ -31,6 +31,9 @@ func TestParseOptions(t *testing.T) { {"hash-files yes", ParseHashFilesOption, "yes", hashFiles, ""}, {"hash-files no", ParseHashFilesOption, "no", skipHashes, ""}, {"hash-files invalid", ParseHashFilesOption, "maybe", "", "invalid -hash-files value"}, + {"browser-history yes", ParseBrowserHistoryOption, "yes", acquireBrowserHistory, ""}, + {"browser-history no", ParseBrowserHistoryOption, "no", skipBrowserHistory, ""}, + {"browser-history invalid", ParseBrowserHistoryOption, "maybe", "", "invalid -browser-history value"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -127,7 +130,7 @@ func TestValidateNonInteractive(t *testing.T) { "nothing set", &Options{NonInteractive: true}, "", - []string{"-backup", "-download", "-intrusion-logs", "-hash-files"}, + []string{"-backup", "-download", "-intrusion-logs", "-hash-files", "-browser-history"}, []string{"-remove-trusted"}, }, { @@ -139,7 +142,7 @@ func TestValidateNonInteractive(t *testing.T) { }, { "download none skips remove-trusted", - &Options{NonInteractive: true, Backup: backupNothing, Download: apkNone, IntrusionLogs: skipIL, HashFiles: skipHashes}, + &Options{NonInteractive: true, Backup: backupNothing, Download: apkNone, IntrusionLogs: skipIL, HashFiles: skipHashes, BrowserHistory: skipBrowserHistory}, "", nil, nil, @@ -149,14 +152,14 @@ func TestValidateNonInteractive(t *testing.T) { &Options{NonInteractive: true}, "backup", []string{"-backup"}, - []string{"-download", "-intrusion-logs", "-hash-files"}, + []string{"-download", "-intrusion-logs", "-hash-files", "-browser-history"}, }, { "files module requires hash choice", &Options{NonInteractive: true}, "files", []string{"-hash-files"}, - []string{"-backup", "-download", "-intrusion-logs"}, + []string{"-backup", "-download", "-intrusion-logs", "-browser-history"}, }, { "files module accepts hash choice", @@ -165,9 +168,16 @@ func TestValidateNonInteractive(t *testing.T) { nil, nil, }, + { + "browser history module requires choice", + &Options{NonInteractive: true}, + "browser_history", + []string{"-browser-history"}, + []string{"-backup", "-download", "-intrusion-logs", "-hash-files"}, + }, { "all set", - &Options{NonInteractive: true, Backup: backupOnlySMS, Download: apkAll, RemoveTrusted: apkKeepAll, IntrusionLogs: skipIL, HashFiles: hashFiles}, + &Options{NonInteractive: true, Backup: backupOnlySMS, Download: apkAll, RemoveTrusted: apkKeepAll, IntrusionLogs: skipIL, HashFiles: hashFiles, BrowserHistory: acquireBrowserHistory}, "", nil, nil,