mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat: add Chromium Browser with new v2 architecture (#530)
* feat: add Chromium Browser implementation with new architecture * refactor: replace Walk with ReadDir+Stat for profile discovery * refactor: remove queries override, use extractors for Yandex passwords * refactor: remove dataSource wrapper, use []sourcePath directly * fix: address Copilot review feedback on chromium_new.go * fix: always call key retriever regardless of Local State existence
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||
"github.com/moond4rk/hackbrowserdata/filemanager"
|
||||
"github.com/moond4rk/hackbrowserdata/log"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
|
||||
)
|
||||
|
||||
// Browser represents a single Chromium profile ready for extraction.
|
||||
type Browser struct {
|
||||
cfg types.BrowserConfig
|
||||
name string // display name: "Chrome-Default"
|
||||
profileDir string // absolute path to profile directory
|
||||
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
||||
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
||||
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
||||
}
|
||||
|
||||
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
|
||||
// one Browser per profile. Uses ReadDir to find profile directories,
|
||||
// then Stat to check which data sources exist in each profile.
|
||||
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||
sources := sourcesForKind(cfg.Kind)
|
||||
extractors := extractorsForKind(cfg.Kind)
|
||||
|
||||
profileDirs := discoverProfiles(cfg.UserDataDir, sources)
|
||||
if len(profileDirs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var browsers []*Browser
|
||||
for _, profileDir := range profileDirs {
|
||||
sourcePaths := resolveSourcePaths(sources, profileDir)
|
||||
if len(sourcePaths) == 0 {
|
||||
continue
|
||||
}
|
||||
browsers = append(browsers, &Browser{
|
||||
cfg: cfg,
|
||||
name: cfg.Name + "-" + filepath.Base(profileDir),
|
||||
profileDir: profileDir,
|
||||
sources: sources,
|
||||
extractors: extractors,
|
||||
sourcePaths: sourcePaths,
|
||||
})
|
||||
}
|
||||
return browsers, nil
|
||||
}
|
||||
|
||||
func (b *Browser) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// Extract copies browser files to a temp directory, retrieves the master key,
|
||||
// and extracts data for the requested categories.
|
||||
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
|
||||
session, err := filemanager.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.Cleanup()
|
||||
|
||||
tempPaths := b.acquireFiles(session, categories)
|
||||
|
||||
masterKey, err := b.getMasterKey(session)
|
||||
if err != nil {
|
||||
log.Debugf("get master key for %s: %v", b.name, err)
|
||||
}
|
||||
|
||||
data := &types.BrowserData{}
|
||||
for _, cat := range categories {
|
||||
path, ok := tempPaths[cat]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
b.extractCategory(data, cat, masterKey, path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// acquireFiles copies source files to the session temp directory.
|
||||
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
|
||||
tempPaths := make(map[types.Category]string)
|
||||
for _, cat := range categories {
|
||||
rp, ok := b.sourcePaths[cat]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(session.TempDir(), cat.String())
|
||||
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
|
||||
log.Debugf("acquire %s: %v", cat, err)
|
||||
continue
|
||||
}
|
||||
tempPaths[cat] = dst
|
||||
}
|
||||
return tempPaths
|
||||
}
|
||||
|
||||
// getMasterKey retrieves the Chromium master encryption key.
|
||||
//
|
||||
// On Windows, the key is read from the Local State file and decrypted via DPAPI.
|
||||
// On macOS, the key is derived from Keychain (Local State is not needed).
|
||||
// On Linux, the key is derived from D-Bus Secret Service or a fallback password.
|
||||
//
|
||||
// The retriever is always called regardless of whether Local State exists,
|
||||
// because macOS/Linux retrievers don't need it.
|
||||
func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) {
|
||||
// Try to locate and copy Local State (needed on Windows, ignored on macOS/Linux).
|
||||
// Multi-profile layout: Local State is in the parent of profileDir.
|
||||
// Flat layout (Opera): Local State is alongside data files in profileDir.
|
||||
var localStateDst string
|
||||
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} {
|
||||
candidate := filepath.Join(dir, "Local State")
|
||||
if fileutil.IsFileExists(candidate) {
|
||||
localStateDst = filepath.Join(session.TempDir(), "Local State")
|
||||
if err := session.Acquire(candidate, localStateDst, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
retriever := keyretriever.DefaultRetriever(b.cfg.KeychainPassword)
|
||||
return retriever.RetrieveKey(b.cfg.Storage, localStateDst)
|
||||
}
|
||||
|
||||
// extractCategory calls the appropriate extract function for a category.
|
||||
// If a custom extractor is registered for this category (via extractorsForKind),
|
||||
// it is used instead of the default switch logic.
|
||||
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) {
|
||||
if ext, ok := b.extractors[cat]; ok {
|
||||
if err := ext.extract(masterKey, path, data); err != nil {
|
||||
log.Debugf("extract %s for %s: %v", cat, b.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch cat {
|
||||
case types.Password:
|
||||
data.Passwords, err = extractPasswords(masterKey, path)
|
||||
case types.Cookie:
|
||||
data.Cookies, err = extractCookies(masterKey, path)
|
||||
case types.History:
|
||||
data.Histories, err = extractHistories(path)
|
||||
case types.Download:
|
||||
data.Downloads, err = extractDownloads(path)
|
||||
case types.Bookmark:
|
||||
data.Bookmarks, err = extractBookmarks(path)
|
||||
case types.CreditCard:
|
||||
data.CreditCards, err = extractCreditCards(masterKey, path)
|
||||
case types.Extension:
|
||||
data.Extensions, err = extractExtensions(path)
|
||||
case types.LocalStorage:
|
||||
data.LocalStorage, err = extractLocalStorage(path)
|
||||
case types.SessionStorage:
|
||||
data.SessionStorage, err = extractSessionStorage(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("extract %s for %s: %v", cat, b.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// discoverProfiles lists subdirectories of userDataDir that contain at least
|
||||
// one known data source. Each such directory is a browser profile.
|
||||
func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePath) []string {
|
||||
entries, err := os.ReadDir(userDataDir)
|
||||
if err != nil {
|
||||
log.Debugf("read user data dir %s: %v", userDataDir, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var profiles []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || isSkippedDir(e.Name()) {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(userDataDir, e.Name())
|
||||
if hasAnySource(sources, dir) {
|
||||
profiles = append(profiles, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// Flat layout fallback (older Opera): data files directly in userDataDir
|
||||
if len(profiles) == 0 && hasAnySource(sources, userDataDir) {
|
||||
profiles = append(profiles, userDataDir)
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
// hasAnySource checks if dir contains at least one source file or directory.
|
||||
func hasAnySource(sources map[types.Category][]sourcePath, dir string) bool {
|
||||
for _, candidates := range sources {
|
||||
for _, sp := range candidates {
|
||||
abs := filepath.Join(dir, sp.rel)
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolvedPath holds the absolute path and type for a discovered source.
|
||||
type resolvedPath struct {
|
||||
absPath string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
// resolveSourcePaths checks which sources actually exist in profileDir.
|
||||
// Candidates are tried in priority order; the first existing path wins.
|
||||
func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir string) map[types.Category]resolvedPath {
|
||||
resolved := make(map[types.Category]resolvedPath)
|
||||
for cat, candidates := range sources {
|
||||
for _, sp := range candidates {
|
||||
abs := filepath.Join(profileDir, sp.rel)
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if sp.isDir == info.IsDir() {
|
||||
resolved[cat] = resolvedPath{abs, sp.isDir}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// isSkippedDir returns true for directory names that should never be
|
||||
// treated as browser profiles.
|
||||
func isSkippedDir(name string) bool {
|
||||
switch name {
|
||||
case "System Profile", "Guest Profile", "Snapshot":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/filemanager"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var fixture struct {
|
||||
root string
|
||||
chrome string // multi-profile + skipped dirs
|
||||
opera string // has Default/
|
||||
operaFlat string // no Default/, data in root
|
||||
yandex string // Ya Passman Data, Ya Credit Cards
|
||||
oldCookies string // Cookies at root (no Network/)
|
||||
bothCookies string // Network/Cookies + Cookies
|
||||
leveldb string // Local Storage/leveldb + Session Storage
|
||||
leveldbOnly string // only LevelDB dirs, no files
|
||||
empty string
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "chromium-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fixture.root = root
|
||||
buildFixtures()
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func buildFixtures() {
|
||||
fixture.chrome = filepath.Join(fixture.root, "chrome")
|
||||
mkFile(fixture.chrome, "Local State")
|
||||
for _, p := range []string{"Default", "Profile 1", "Profile 3"} {
|
||||
mkFile(fixture.chrome, p, "Login Data")
|
||||
mkFile(fixture.chrome, p, "History")
|
||||
mkFile(fixture.chrome, p, "Bookmarks")
|
||||
mkFile(fixture.chrome, p, "Web Data")
|
||||
mkFile(fixture.chrome, p, "Secure Preferences")
|
||||
mkFile(fixture.chrome, p, "Network", "Cookies")
|
||||
mkDir(fixture.chrome, p, "Local Storage", "leveldb")
|
||||
mkDir(fixture.chrome, p, "Session Storage")
|
||||
}
|
||||
mkFile(fixture.chrome, "System Profile", "History")
|
||||
mkFile(fixture.chrome, "Guest Profile", "History")
|
||||
mkFile(fixture.chrome, "Snapshot", "Default", "History")
|
||||
|
||||
fixture.opera = filepath.Join(fixture.root, "opera")
|
||||
mkFile(fixture.opera, "Local State")
|
||||
mkFile(fixture.opera, "Default", "Login Data")
|
||||
mkFile(fixture.opera, "Default", "History")
|
||||
mkFile(fixture.opera, "Default", "Bookmarks")
|
||||
mkFile(fixture.opera, "Default", "Cookies")
|
||||
|
||||
fixture.operaFlat = filepath.Join(fixture.root, "opera-flat")
|
||||
mkFile(fixture.operaFlat, "Local State")
|
||||
mkFile(fixture.operaFlat, "Login Data")
|
||||
mkFile(fixture.operaFlat, "History")
|
||||
mkFile(fixture.operaFlat, "Cookies")
|
||||
|
||||
fixture.yandex = filepath.Join(fixture.root, "yandex")
|
||||
mkFile(fixture.yandex, "Local State")
|
||||
mkFile(fixture.yandex, "Default", "Ya Passman Data")
|
||||
mkFile(fixture.yandex, "Default", "Ya Credit Cards")
|
||||
mkFile(fixture.yandex, "Default", "History")
|
||||
mkFile(fixture.yandex, "Default", "Network", "Cookies")
|
||||
mkFile(fixture.yandex, "Default", "Bookmarks")
|
||||
|
||||
fixture.oldCookies = filepath.Join(fixture.root, "old-cookies")
|
||||
mkFile(fixture.oldCookies, "Default", "History")
|
||||
mkFile(fixture.oldCookies, "Default", "Cookies")
|
||||
|
||||
fixture.bothCookies = filepath.Join(fixture.root, "both-cookies")
|
||||
mkFile(fixture.bothCookies, "Default", "Cookies")
|
||||
mkFile(fixture.bothCookies, "Default", "Network", "Cookies")
|
||||
|
||||
fixture.leveldb = filepath.Join(fixture.root, "leveldb")
|
||||
mkFile(fixture.leveldb, "Default", "History")
|
||||
mkDir(fixture.leveldb, "Default", "Local Storage", "leveldb")
|
||||
mkFile(fixture.leveldb, "Default", "Local Storage", "leveldb", "000001.ldb")
|
||||
mkDir(fixture.leveldb, "Default", "Session Storage")
|
||||
mkFile(fixture.leveldb, "Default", "Session Storage", "000001.ldb")
|
||||
|
||||
fixture.leveldbOnly = filepath.Join(fixture.root, "leveldb-only")
|
||||
mkDir(fixture.leveldbOnly, "Default", "Local Storage", "leveldb")
|
||||
mkDir(fixture.leveldbOnly, "Default", "Session Storage")
|
||||
|
||||
fixture.empty = filepath.Join(fixture.root, "empty")
|
||||
mkDir(fixture.empty)
|
||||
}
|
||||
|
||||
func mkFile(parts ...string) {
|
||||
path := filepath.Join(parts...)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mkDir(parts ...string) {
|
||||
if err := os.MkdirAll(filepath.Join(parts...), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewBrowsers: table-driven, covers all layouts end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewBrowsers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
kind types.BrowserKind
|
||||
wantProfiles []string // expected profile base names
|
||||
wantCats map[string][]string // profile → expected category base names (spot check)
|
||||
wantDirs []types.Category // categories that should be isDir=true
|
||||
skipProfiles []string // should NOT appear
|
||||
}{
|
||||
{
|
||||
name: "chrome multi-profile",
|
||||
dir: fixture.chrome,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default", "Profile 1", "Profile 3"},
|
||||
wantCats: map[string][]string{
|
||||
"Default": {"Login Data", "Cookies", "History", "Bookmarks", "Web Data", "Secure Preferences", "leveldb", "Session Storage"},
|
||||
},
|
||||
wantDirs: []types.Category{types.LocalStorage, types.SessionStorage},
|
||||
skipProfiles: []string{"System Profile", "Guest Profile", "Snapshot"},
|
||||
},
|
||||
{
|
||||
name: "opera with Default",
|
||||
dir: fixture.opera,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default"},
|
||||
wantCats: map[string][]string{
|
||||
"Default": {"Login Data", "History", "Bookmarks", "Cookies"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opera flat layout",
|
||||
dir: fixture.operaFlat,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{filepath.Base(fixture.operaFlat)}, // userDataDir itself
|
||||
wantCats: map[string][]string{
|
||||
filepath.Base(fixture.operaFlat): {"Login Data", "History", "Cookies"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "yandex custom files",
|
||||
dir: fixture.yandex,
|
||||
kind: types.KindChromiumYandex,
|
||||
wantProfiles: []string{"Default"},
|
||||
wantCats: map[string][]string{
|
||||
"Default": {"Ya Passman Data", "Ya Credit Cards", "History", "Cookies", "Bookmarks"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "old cookies fallback",
|
||||
dir: fixture.oldCookies,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default"},
|
||||
},
|
||||
{
|
||||
name: "cookie priority",
|
||||
dir: fixture.bothCookies,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default"},
|
||||
},
|
||||
{
|
||||
name: "leveldb directories",
|
||||
dir: fixture.leveldb,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default"},
|
||||
wantDirs: []types.Category{types.LocalStorage, types.SessionStorage},
|
||||
},
|
||||
{
|
||||
name: "leveldb only",
|
||||
dir: fixture.leveldbOnly,
|
||||
kind: types.KindChromium,
|
||||
wantProfiles: []string{"Default"},
|
||||
wantDirs: []types.Category{types.LocalStorage, types.SessionStorage},
|
||||
},
|
||||
{
|
||||
name: "empty dir",
|
||||
dir: fixture.empty,
|
||||
kind: types.KindChromium,
|
||||
},
|
||||
{
|
||||
name: "nonexistent dir",
|
||||
dir: "/nonexistent/path",
|
||||
kind: types.KindChromium,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := types.BrowserConfig{Name: "Test", Kind: tt.kind, UserDataDir: tt.dir}
|
||||
browsers, err := NewBrowsers(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(tt.wantProfiles) == 0 {
|
||||
assert.Empty(t, browsers)
|
||||
return
|
||||
}
|
||||
require.Len(t, browsers, len(tt.wantProfiles))
|
||||
|
||||
nameMap := browsersByProfile(browsers)
|
||||
assertProfiles(t, nameMap, tt.wantProfiles, tt.skipProfiles)
|
||||
assertCategories(t, nameMap, tt.wantCats)
|
||||
assertDirCategories(t, browsers, tt.wantDirs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func browsersByProfile(browsers []*Browser) map[string]*Browser {
|
||||
m := make(map[string]*Browser, len(browsers))
|
||||
for _, b := range browsers {
|
||||
m[filepath.Base(b.profileDir)] = b
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func assertProfiles(t *testing.T, nameMap map[string]*Browser, want, skip []string) {
|
||||
t.Helper()
|
||||
for _, w := range want {
|
||||
assert.Contains(t, nameMap, w, "should find profile %s", w)
|
||||
}
|
||||
for _, s := range skip {
|
||||
assert.NotContains(t, nameMap, s, "should skip %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCategories(t *testing.T, nameMap map[string]*Browser, wantCats map[string][]string) {
|
||||
t.Helper()
|
||||
for profileName, wantFiles := range wantCats {
|
||||
b, ok := nameMap[profileName]
|
||||
if !ok {
|
||||
t.Errorf("profile %s not found", profileName)
|
||||
continue
|
||||
}
|
||||
for _, wantFile := range wantFiles {
|
||||
found := false
|
||||
for _, rp := range b.sourcePaths {
|
||||
if filepath.Base(rp.absPath) == wantFile {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "profile %s should have %s", profileName, wantFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDirCategories(t *testing.T, browsers []*Browser, cats []types.Category) {
|
||||
t.Helper()
|
||||
for _, cat := range cats {
|
||||
for _, b := range browsers {
|
||||
if rp, ok := b.sourcePaths[cat]; ok {
|
||||
assert.True(t, rp.isDir, "%s should be isDir=true", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie priority: Network/Cookies wins over root Cookies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCookiePriority(t *testing.T) {
|
||||
resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.bothCookies, "Default"))
|
||||
require.Contains(t, resolved, types.Cookie)
|
||||
assert.Contains(t, resolved[types.Cookie].absPath, "Network",
|
||||
"Network/Cookies should win over root Cookies")
|
||||
}
|
||||
|
||||
func TestCookieFallback(t *testing.T) {
|
||||
resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.oldCookies, "Default"))
|
||||
require.Contains(t, resolved, types.Cookie)
|
||||
assert.NotContains(t, resolved[types.Cookie].absPath, "Network",
|
||||
"should fallback to root Cookies when Network/Cookies missing")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History/Download share the same source file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSharedSourceFile(t *testing.T) {
|
||||
resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.chrome, "Default"))
|
||||
assert.Equal(t, resolved[types.History].absPath, resolved[types.Download].absPath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSourcesForKind(t *testing.T) {
|
||||
chromium := sourcesForKind(types.KindChromium)
|
||||
yandex := sourcesForKind(types.KindChromiumYandex)
|
||||
|
||||
assert.Equal(t, "Login Data", chromium[types.Password][0].rel)
|
||||
assert.Equal(t, "Ya Passman Data", yandex[types.Password][0].rel)
|
||||
// Yandex inherits non-overridden categories
|
||||
assert.Equal(t, chromium[types.History][0].rel, yandex[types.History][0].rel)
|
||||
}
|
||||
|
||||
func TestExtractorsForKind(t *testing.T) {
|
||||
assert.Nil(t, extractorsForKind(types.KindChromium))
|
||||
|
||||
yandexExt := extractorsForKind(types.KindChromiumYandex)
|
||||
require.NotNil(t, yandexExt)
|
||||
assert.Contains(t, yandexExt, types.Password)
|
||||
|
||||
operaExt := extractorsForKind(types.KindChromiumOpera)
|
||||
require.NotNil(t, operaExt)
|
||||
assert.Contains(t, operaExt, types.Extension)
|
||||
}
|
||||
|
||||
// TestExtractCategory_CustomExtractor verifies that extractCategory dispatches
|
||||
// through a registered extractor instead of the default switch logic.
|
||||
func TestExtractCategory_CustomExtractor(t *testing.T) {
|
||||
// Create a Browser with a custom extractor that records it was called
|
||||
called := false
|
||||
testExtractor := extensionExtractor{
|
||||
fn: func(path string) ([]types.ExtensionEntry, error) {
|
||||
called = true
|
||||
return []types.ExtensionEntry{{Name: "custom", ID: "test-id"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
b := &Browser{
|
||||
extractors: map[types.Category]categoryExtractor{
|
||||
types.Extension: testExtractor,
|
||||
},
|
||||
}
|
||||
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.Extension, nil, "unused-path")
|
||||
|
||||
assert.True(t, called, "custom extractor should be called")
|
||||
require.Len(t, data.Extensions, 1)
|
||||
assert.Equal(t, "custom", data.Extensions[0].Name)
|
||||
}
|
||||
|
||||
// TestExtractCategory_DefaultFallback verifies that extractCategory uses
|
||||
// the default switch when no extractor is registered.
|
||||
func TestExtractCategory_DefaultFallback(t *testing.T) {
|
||||
path := createTestDB(t, "History", urlsSchema,
|
||||
insertURL("https://example.com", "Example", 3, 13350000000000000),
|
||||
)
|
||||
|
||||
b := &Browser{
|
||||
name: "Test",
|
||||
extractors: nil, // no custom extractors
|
||||
}
|
||||
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.History, nil, path)
|
||||
|
||||
require.Len(t, data.Histories, 1)
|
||||
assert.Equal(t, "Example", data.Histories[0].Title)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acquireFiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAcquireFiles(t *testing.T) {
|
||||
profileDir := filepath.Join(fixture.chrome, "Default")
|
||||
resolved := resolveSourcePaths(chromiumSources, profileDir)
|
||||
|
||||
b := &Browser{profileDir: profileDir, sources: chromiumSources, sourcePaths: resolved}
|
||||
|
||||
session, err := filemanager.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Cleanup()
|
||||
|
||||
cats := []types.Category{types.History, types.Cookie, types.Bookmark}
|
||||
paths := b.acquireFiles(session, cats)
|
||||
|
||||
assert.Len(t, paths, len(cats))
|
||||
for _, p := range paths {
|
||||
_, err := os.Stat(p)
|
||||
assert.NoError(t, err, "acquired file should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local State path validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLocalStatePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
want bool // Local State should be at Dir(profileDir)/Local State
|
||||
}{
|
||||
{"chrome", fixture.chrome, true},
|
||||
{"opera", fixture.opera, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
browsers, err := NewBrowsers(types.BrowserConfig{Name: "Test", Kind: types.KindChromium, UserDataDir: tt.dir})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, browsers)
|
||||
|
||||
for _, b := range browsers {
|
||||
localState := filepath.Join(filepath.Dir(b.profileDir), "Local State")
|
||||
if tt.want {
|
||||
assert.FileExists(t, localState)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,28 @@ import (
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
// defaultExtensionKeys are the JSON paths tried for standard Chromium browsers.
|
||||
var defaultExtensionKeys = []string{
|
||||
"extensions.settings",
|
||||
"settings.extensions",
|
||||
"settings.settings",
|
||||
}
|
||||
|
||||
func extractExtensions(path string) ([]types.ExtensionEntry, error) {
|
||||
return extractExtensionsWithKeys(path, defaultExtensionKeys)
|
||||
}
|
||||
|
||||
// extractExtensionsWithKeys reads Secure Preferences and looks for extension
|
||||
// settings under the given JSON key paths. This allows browser variants
|
||||
// (e.g. Opera with "extensions.opsettings") to reuse the same parsing logic.
|
||||
func extractExtensionsWithKeys(path string, keys []string) ([]types.ExtensionEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try known JSON paths for extension settings
|
||||
settingKeys := []string{
|
||||
"extensions.settings",
|
||||
"settings.extensions",
|
||||
"settings.settings",
|
||||
}
|
||||
var settings gjson.Result
|
||||
for _, key := range settingKeys {
|
||||
for _, key := range keys {
|
||||
settings = gjson.GetBytes(data, key)
|
||||
if settings.Exists() {
|
||||
break
|
||||
@@ -59,3 +67,10 @@ func extractExtensions(path string) ([]types.ExtensionEntry, error) {
|
||||
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
// extractOperaExtensions extracts extensions from Opera's Secure Preferences,
|
||||
// which stores extension data under "extensions.opsettings" instead of the
|
||||
// standard "extensions.settings".
|
||||
func extractOperaExtensions(path string) ([]types.ExtensionEntry, error) {
|
||||
return extractExtensionsWithKeys(path, []string{"extensions.opsettings"})
|
||||
}
|
||||
|
||||
@@ -75,3 +75,37 @@ func TestExtractExtensions_MissingSettingsPath(t *testing.T) {
|
||||
_, err := extractExtensions(path)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExtractOperaExtensions(t *testing.T) {
|
||||
path := createTestJSON(t, "Secure Preferences", `{
|
||||
"extensions": {
|
||||
"opsettings": {
|
||||
"opera-ext-1": {
|
||||
"location": 1,
|
||||
"manifest": {
|
||||
"name": "Opera Ad Blocker",
|
||||
"description": "Blocks ads in Opera",
|
||||
"version": "2.0.0"
|
||||
},
|
||||
"state": 1
|
||||
},
|
||||
"system-ext": {
|
||||
"location": 5,
|
||||
"manifest": {"name": "System", "version": "1.0"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
// extractOperaExtensions should find extensions under opsettings
|
||||
got, err := extractOperaExtensions(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1) // system extension skipped
|
||||
assert.Equal(t, "Opera Ad Blocker", got[0].Name)
|
||||
assert.Equal(t, "2.0.0", got[0].Version)
|
||||
assert.True(t, got[0].Enabled)
|
||||
|
||||
// Standard extractExtensions should fail on the same file (no "extensions.settings")
|
||||
_, err = extractExtensions(path)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
const defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins`
|
||||
|
||||
func extractPasswords(masterKey []byte, path, query string) ([]types.LoginEntry, error) {
|
||||
if query == "" {
|
||||
query = defaultLoginQuery
|
||||
}
|
||||
func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) {
|
||||
return extractPasswordsWithQuery(masterKey, path, defaultLoginQuery)
|
||||
}
|
||||
|
||||
func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.LoginEntry, error) {
|
||||
logins, err := sqliteutil.QueryRows(path, false, query,
|
||||
func(rows *sql.Rows) (types.LoginEntry, error) {
|
||||
var url, username string
|
||||
@@ -41,3 +41,10 @@ func extractPasswords(masterKey []byte, path, query string) ([]types.LoginEntry,
|
||||
})
|
||||
return logins, nil
|
||||
}
|
||||
|
||||
// extractYandexPasswords extracts passwords from Yandex's Ya Passman Data,
|
||||
// which stores the URL in action_url instead of origin_url.
|
||||
func extractYandexPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) {
|
||||
const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins`
|
||||
return extractPasswordsWithQuery(masterKey, path, yandexLoginQuery)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
func TestExtractPasswords(t *testing.T) {
|
||||
@@ -15,7 +13,7 @@ func TestExtractPasswords(t *testing.T) {
|
||||
insertLogin("https://new.com", "https://new.com/login", "bob", "", 13360000000000000),
|
||||
)
|
||||
|
||||
got, err := extractPasswords(nil, path, "")
|
||||
got, err := extractPasswords(nil, path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
@@ -30,13 +28,12 @@ func TestExtractPasswords(t *testing.T) {
|
||||
assert.Empty(t, got[0].Password)
|
||||
}
|
||||
|
||||
func TestExtractPasswords_YandexQueryOverride(t *testing.T) {
|
||||
func TestExtractYandexPasswords(t *testing.T) {
|
||||
path := createTestDB(t, "Ya Passman Data", loginsSchema,
|
||||
insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000),
|
||||
)
|
||||
|
||||
// Yandex uses action_url instead of origin_url
|
||||
got, err := extractPasswords(nil, path, yandexQueryOverrides[types.Password])
|
||||
got, err := extractYandexPasswords(nil, path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url
|
||||
|
||||
+97
-26
@@ -1,37 +1,45 @@
|
||||
package chromium
|
||||
|
||||
import "github.com/moond4rk/hackbrowserdata/types"
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
// dataSource maps a Category to one or more candidate file paths within a profile directory.
|
||||
// paths are tried in order; the first one that exists is used.
|
||||
type dataSource struct {
|
||||
paths []string // candidate relative paths in priority order
|
||||
isDir bool // true for LevelDB directories
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
// sourcePath describes a single candidate location for browser data,
|
||||
// relative to the profile directory.
|
||||
type sourcePath struct {
|
||||
rel string // relative path from profileDir, e.g. "Network/Cookies"
|
||||
isDir bool // true for directory targets (LevelDB, Session Storage)
|
||||
}
|
||||
|
||||
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
|
||||
func dir(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: true} }
|
||||
|
||||
// chromiumSources defines the standard Chromium file layout.
|
||||
var chromiumSources = map[types.Category]dataSource{
|
||||
types.Password: {paths: []string{"Login Data"}},
|
||||
types.Cookie: {paths: []string{"Network/Cookies", "Cookies"}},
|
||||
types.History: {paths: []string{"History"}},
|
||||
types.Download: {paths: []string{"History"}}, // same file, different query
|
||||
types.Bookmark: {paths: []string{"Bookmarks"}},
|
||||
types.CreditCard: {paths: []string{"Web Data"}},
|
||||
types.Extension: {paths: []string{"Secure Preferences"}},
|
||||
types.LocalStorage: {paths: []string{"Local Storage/leveldb"}, isDir: true},
|
||||
types.SessionStorage: {paths: []string{"Session Storage"}, isDir: true},
|
||||
// Each category maps to one or more candidate paths tried in priority order;
|
||||
// the first existing path wins.
|
||||
var chromiumSources = map[types.Category][]sourcePath{
|
||||
types.Password: {file("Login Data")},
|
||||
types.Cookie: {file("Network/Cookies"), file("Cookies")},
|
||||
types.History: {file("History")},
|
||||
types.Download: {file("History")},
|
||||
types.Bookmark: {file("Bookmarks")},
|
||||
types.CreditCard: {file("Web Data")},
|
||||
types.Extension: {file("Secure Preferences")},
|
||||
types.LocalStorage: {dir("Local Storage/leveldb")},
|
||||
types.SessionStorage: {dir("Session Storage")},
|
||||
}
|
||||
|
||||
// yandexSourceOverrides contains only the entries that differ from chromiumSources.
|
||||
// At initialization time, these are merged into a copy of chromiumSources.
|
||||
var yandexSourceOverrides = map[types.Category]dataSource{
|
||||
types.Password: {paths: []string{"Ya Passman Data"}},
|
||||
types.CreditCard: {paths: []string{"Ya Credit Cards"}},
|
||||
var yandexSourceOverrides = map[types.Category][]sourcePath{
|
||||
types.Password: {file("Ya Passman Data")},
|
||||
types.CreditCard: {file("Ya Credit Cards")},
|
||||
}
|
||||
|
||||
// yandexSources returns chromiumSources with Yandex-specific overrides applied.
|
||||
func yandexSources() map[types.Category]dataSource {
|
||||
sources := make(map[types.Category]dataSource, len(chromiumSources))
|
||||
func yandexSources() map[types.Category][]sourcePath {
|
||||
sources := make(map[types.Category][]sourcePath, len(chromiumSources))
|
||||
for k, v := range chromiumSources {
|
||||
sources[k] = v
|
||||
}
|
||||
@@ -41,8 +49,71 @@ func yandexSources() map[types.Category]dataSource {
|
||||
return sources
|
||||
}
|
||||
|
||||
// yandexQueryOverrides provides SQL query overrides for Yandex Browser.
|
||||
// Yandex uses action_url instead of origin_url for password storage.
|
||||
var yandexQueryOverrides = map[types.Category]string{
|
||||
types.Password: `SELECT action_url, username_value, password_value, date_created FROM logins`,
|
||||
// sourcesForKind returns the source mapping for a browser kind.
|
||||
func sourcesForKind(kind types.BrowserKind) map[types.Category][]sourcePath {
|
||||
switch kind {
|
||||
case types.KindChromiumYandex:
|
||||
return yandexSources()
|
||||
default:
|
||||
return chromiumSources
|
||||
}
|
||||
}
|
||||
|
||||
// categoryExtractor extracts data for a single category into BrowserData.
|
||||
// Implementations wrap typed extract functions to provide a uniform dispatch
|
||||
// interface while preserving the original function signatures.
|
||||
//
|
||||
// Use extractorsForKind to register per-Kind overrides. When an extractor
|
||||
// is present for a category, extractCategory uses it instead of the default
|
||||
// switch logic, enabling browser-specific parsing (e.g. Opera's opsettings
|
||||
// for extensions, Yandex's credit card table, QBCI-encrypted bookmarks).
|
||||
type categoryExtractor interface {
|
||||
extract(masterKey []byte, path string, data *types.BrowserData) error
|
||||
}
|
||||
|
||||
// passwordExtractor wraps a custom password extract function.
|
||||
type passwordExtractor struct {
|
||||
fn func(masterKey []byte, path string) ([]types.LoginEntry, error)
|
||||
}
|
||||
|
||||
func (e passwordExtractor) extract(masterKey []byte, path string, data *types.BrowserData) error {
|
||||
var err error
|
||||
data.Passwords, err = e.fn(masterKey, path)
|
||||
return err
|
||||
}
|
||||
|
||||
// extensionExtractor wraps a custom extension extract function.
|
||||
type extensionExtractor struct {
|
||||
fn func(path string) ([]types.ExtensionEntry, error)
|
||||
}
|
||||
|
||||
func (e extensionExtractor) extract(_ []byte, path string, data *types.BrowserData) error {
|
||||
var err error
|
||||
data.Extensions, err = e.fn(path)
|
||||
return err
|
||||
}
|
||||
|
||||
// yandexExtractors overrides Password extraction for Yandex,
|
||||
// which uses action_url instead of origin_url.
|
||||
var yandexExtractors = map[types.Category]categoryExtractor{
|
||||
types.Password: passwordExtractor{fn: extractYandexPasswords},
|
||||
}
|
||||
|
||||
// operaExtractors overrides Extension extraction for Opera,
|
||||
// which stores settings under "extensions.opsettings".
|
||||
var operaExtractors = map[types.Category]categoryExtractor{
|
||||
types.Extension: extensionExtractor{fn: extractOperaExtensions},
|
||||
}
|
||||
|
||||
// extractorsForKind returns custom category extractors for a browser kind.
|
||||
// nil means all categories use the default extractCategory switch logic.
|
||||
func extractorsForKind(kind types.BrowserKind) map[types.Category]categoryExtractor {
|
||||
switch kind {
|
||||
case types.KindChromiumYandex:
|
||||
return yandexExtractors
|
||||
case types.KindChromiumOpera:
|
||||
return operaExtractors
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
+23
-11
@@ -1,21 +1,33 @@
|
||||
package firefox
|
||||
|
||||
import "github.com/moond4rk/hackbrowserdata/types"
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
// dataSource maps a Category to one or more candidate file paths within a profile directory.
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
// sourcePath describes a single candidate location for browser data,
|
||||
// relative to the profile directory.
|
||||
type sourcePath struct {
|
||||
rel string // relative path from profileDir
|
||||
isDir bool // true for directory targets
|
||||
}
|
||||
|
||||
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
|
||||
|
||||
// dataSource holds one or more candidate sourcePaths in priority order.
|
||||
type dataSource struct {
|
||||
paths []string // candidate relative paths in priority order
|
||||
isDir bool // true for directories (unused in Firefox, all sources are files)
|
||||
candidates []sourcePath
|
||||
}
|
||||
|
||||
// firefoxSources defines the Firefox file layout.
|
||||
// Firefox does not support SessionStorage or CreditCard extraction.
|
||||
var firefoxSources = map[types.Category]dataSource{
|
||||
types.Password: {paths: []string{"logins.json"}},
|
||||
types.Cookie: {paths: []string{"cookies.sqlite"}},
|
||||
types.History: {paths: []string{"places.sqlite"}},
|
||||
types.Download: {paths: []string{"places.sqlite"}}, // same file as History
|
||||
types.Bookmark: {paths: []string{"places.sqlite"}}, // same file as History
|
||||
types.Extension: {paths: []string{"extensions.json"}},
|
||||
types.LocalStorage: {paths: []string{"webappsstore.sqlite"}},
|
||||
types.Password: {candidates: []sourcePath{file("logins.json")}},
|
||||
types.Cookie: {candidates: []sourcePath{file("cookies.sqlite")}},
|
||||
types.History: {candidates: []sourcePath{file("places.sqlite")}},
|
||||
types.Download: {candidates: []sourcePath{file("places.sqlite")}},
|
||||
types.Bookmark: {candidates: []sourcePath{file("places.sqlite")}},
|
||||
types.Extension: {candidates: []sourcePath{file("extensions.json")}},
|
||||
types.LocalStorage: {candidates: []sourcePath{file("webappsstore.sqlite")}},
|
||||
}
|
||||
|
||||
@@ -69,3 +69,36 @@ func NonSensitiveCategories() []Category {
|
||||
}
|
||||
return cats
|
||||
}
|
||||
|
||||
// BrowserKind identifies the browser engine type.
|
||||
type BrowserKind int
|
||||
|
||||
const (
|
||||
KindChromium BrowserKind = iota
|
||||
KindChromiumYandex // Chromium variant with different file names and extract logic
|
||||
KindChromiumOpera // Opera: extensions in "opsettings" key, data in Roaming
|
||||
KindFirefox
|
||||
)
|
||||
|
||||
// BrowserConfig holds the declarative configuration for a browser installation.
|
||||
type BrowserConfig struct {
|
||||
Key string // lookup key: "chrome", "edge", "firefox"
|
||||
Name string // display name: "Chrome", "Edge", "Firefox"
|
||||
Kind BrowserKind // engine type
|
||||
Storage string // keychain/GNOME label (macOS/Linux); unused on Windows
|
||||
KeychainPassword string // macOS login password for KeychainPasswordRetriever; ignored on Windows/Linux
|
||||
UserDataDir string // base browser directory
|
||||
}
|
||||
|
||||
// BrowserData holds all extracted browser data with typed slices.
|
||||
type BrowserData struct {
|
||||
Passwords []LoginEntry
|
||||
Cookies []CookieEntry
|
||||
Histories []HistoryEntry
|
||||
Downloads []DownloadEntry
|
||||
Bookmarks []BookmarkEntry
|
||||
CreditCards []CreditCardEntry
|
||||
Extensions []ExtensionEntry
|
||||
LocalStorage []StorageEntry
|
||||
SessionStorage []StorageEntry
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user