Skip to content

Commit 860456a

Browse files
authored
Add support for Opera/Opera GX browser (#1629)
1 parent 3592dbe commit 860456a

File tree

7 files changed

+331
-1
lines changed

7 files changed

+331
-1
lines changed

dissect/target/plugins/apps/browser/chromium.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,10 @@ def cookies(self, browser_name: str | None = None) -> Iterator[BrowserCookieReco
308308
)
309309

310310
# Strip extra data
311-
if cookie_value and encrypted_cookie[0:3] == b"v20":
311+
if cookie_value and (
312+
encrypted_cookie[0:3] == b"v20"
313+
or (encrypted_cookie[0:3] == b"v10" and browser_name == "opera")
314+
):
312315
cookie_value = cookie_value[32:]
313316

314317
yield self.BrowserCookieRecord(
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from dissect.util.ts import webkittimestamp
6+
7+
from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
8+
from dissect.target.helpers.record import create_extended_descriptor
9+
from dissect.target.plugin import export
10+
from dissect.target.plugins.apps.browser.browser import (
11+
GENERIC_COOKIE_FIELDS,
12+
GENERIC_DOWNLOAD_RECORD_FIELDS,
13+
GENERIC_EXTENSION_RECORD_FIELDS,
14+
GENERIC_HISTORY_RECORD_FIELDS,
15+
GENERIC_PASSWORD_RECORD_FIELDS,
16+
BrowserPlugin,
17+
)
18+
from dissect.target.plugins.apps.browser.chromium import (
19+
CHROMIUM_DOWNLOAD_RECORD_FIELDS,
20+
ChromiumMixin,
21+
)
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Iterator
25+
26+
27+
OPERA_EXTENSION_RECORD_FIELDS = [
28+
("boolean", "blacklisted"),
29+
]
30+
31+
32+
class OperaPlugin(ChromiumMixin, BrowserPlugin):
33+
"""Opera (Stable and Opera GX) browser plugin."""
34+
35+
__namespace__ = "opera"
36+
37+
DIRS = (
38+
# Windows (Stable)
39+
"AppData/Roaming/Opera Software/Opera Stable/Default",
40+
"AppData/Roaming/Opera Software/Opera Stable/_side_profiles/*/Default",
41+
"AppData/Local/Opera Software/Opera Stable/Default",
42+
"AppData/Local/Opera Software/Opera Stable/_side_profiles/*/Default",
43+
# Windows (GX)
44+
"AppData/Roaming/Opera Software/Opera GX Stable/Default",
45+
"AppData/Roaming/Opera Software/Opera GX Stable/_side_profiles/*/Default",
46+
"AppData/Local/Opera Software/Opera GX Stable/Default",
47+
"AppData/Local/Opera Software/Opera GX Stable/_side_profiles/*/Default",
48+
# MacOS (Stable)
49+
"Library/Application Support/com.operasoftware.Opera/Default",
50+
"Library/Application Support/com.operasoftware.Opera/_side_profiles/*/Default",
51+
# MacOS (GX)
52+
"Library/Application Support/com.operasoftware.OperaGX/_side_profiles/*/Default",
53+
"Library/Application Support/com.operasoftware.OperaGX/Default",
54+
)
55+
56+
BrowserHistoryRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
57+
"application/browser/opera/history",
58+
GENERIC_HISTORY_RECORD_FIELDS,
59+
)
60+
61+
BrowserCookieRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
62+
"application/browser/opera/cookie",
63+
GENERIC_COOKIE_FIELDS,
64+
)
65+
66+
BrowserDownloadRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
67+
"application/browser/opera/download",
68+
GENERIC_DOWNLOAD_RECORD_FIELDS + CHROMIUM_DOWNLOAD_RECORD_FIELDS,
69+
)
70+
71+
BrowserExtensionRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
72+
"application/browser/opera/extension",
73+
GENERIC_EXTENSION_RECORD_FIELDS + OPERA_EXTENSION_RECORD_FIELDS,
74+
)
75+
76+
BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
77+
"application/browser/opera/password",
78+
GENERIC_PASSWORD_RECORD_FIELDS,
79+
)
80+
81+
@export(record=BrowserHistoryRecord)
82+
def history(self) -> Iterator[BrowserHistoryRecord]:
83+
"""Return browser history records for Opera (and Opera GX)."""
84+
yield from super().history("opera")
85+
86+
@export(record=BrowserCookieRecord)
87+
def cookies(self) -> Iterator[BrowserCookieRecord]:
88+
"""Return browser cookie records for Opera (and Opera GX)."""
89+
yield from super().cookies("opera")
90+
91+
@export(record=BrowserDownloadRecord)
92+
def downloads(self) -> Iterator[BrowserDownloadRecord]:
93+
"""Return browser download records for Opera (and Opera GX)."""
94+
yield from super().downloads("opera")
95+
96+
@export(record=BrowserExtensionRecord)
97+
def extensions(self) -> Iterator[BrowserExtensionRecord]:
98+
"""Iterates over all installed extensions for Opera (and Opera GX).
99+
100+
Yields:
101+
102+
.. code-block:: text
103+
104+
Records with the following fields:
105+
blacklisted (boolean): Extension blacklisted by Opera/Opera GX.
106+
ts_install (datetime): Extension install timestamp.
107+
ts_update (datetime): Extension update timestamp.
108+
browser (string): The browser from which the records are generated.
109+
id (string): Extension unique identifier.
110+
name (string): Name of the extension.
111+
short_name (string): Short name of the extension.
112+
default_title (string): Default title of the extension.
113+
description (string): Description of the extension.
114+
version (string): Version of the extension.
115+
ext_path (path): Relative path of the extension.
116+
from_webstore (boolean): Extension from webstore.
117+
permissions (string[]): Permissions of the extension.
118+
manifest (varint): Version of the extensions' manifest.
119+
source: (path): The source file of the download record.
120+
"""
121+
for user, json_file, content in self._iter_json("Secure Preferences"):
122+
try:
123+
extensions = content.get("extensions").get("opsettings")
124+
for extension_id, extension_data in extensions.items():
125+
# Opera includes a bunch of empty (prefilled) blacklisted extensions with only one key called
126+
# 'blacklist_state'. If no other metadata is present, it's not installed. Filtering based on if
127+
# the extension itself is blacklisted is a no-go as you can still install blacklisted extensions.
128+
blacklisted = bool(extension_data.get("blacklist_state", 0))
129+
if blacklisted and len(extension_data.keys()) == 1:
130+
continue
131+
132+
ts_install = extension_data.get("first_install_time") or extension_data.get("install_time")
133+
ts_update = extension_data.get("last_update_time")
134+
135+
if ts_install:
136+
ts_install = webkittimestamp(ts_install)
137+
if ts_update:
138+
ts_update = webkittimestamp(ts_update)
139+
140+
if ext_path := extension_data.get("path"):
141+
ext_path = self.target.fs.path(ext_path)
142+
143+
manifest = extension_data.get("manifest")
144+
if manifest:
145+
name = manifest.get("name")
146+
short_name = manifest.get("short_name")
147+
description = manifest.get("description")
148+
ext_version = manifest.get("version")
149+
ext_permissions = manifest.get("permissions")
150+
manifest_version = manifest.get("manifest_version")
151+
152+
if manifest.get("browser_action"):
153+
default_title = manifest.get("browser_action").get("default_title")
154+
else:
155+
default_title = None
156+
157+
else:
158+
name = None
159+
short_name = None
160+
default_title = None
161+
description = None
162+
ext_version = None
163+
ext_permissions = None
164+
manifest_version = None
165+
166+
yield self.BrowserExtensionRecord(
167+
blacklisted=blacklisted,
168+
ts_install=ts_install,
169+
ts_update=ts_update,
170+
browser=self.__namespace__,
171+
extension_id=extension_id,
172+
name=name,
173+
short_name=short_name,
174+
default_title=default_title,
175+
description=description,
176+
version=ext_version,
177+
ext_path=ext_path,
178+
from_webstore=extensions.get(extension_id).get("from_webstore"),
179+
permissions=ext_permissions,
180+
manifest_version=manifest_version,
181+
source=json_file,
182+
_target=self.target,
183+
_user=user.user,
184+
)
185+
except (AttributeError, KeyError) as e:
186+
self.target.log.warning("No browser extensions found in: %s", json_file)
187+
self.target.log.debug("", exc_info=e)
188+
189+
@export(record=BrowserPasswordRecord)
190+
def passwords(self) -> Iterator[BrowserPasswordRecord]:
191+
"""Return browser password records for Opera (and Opera GX)."""
192+
yield from super().passwords("opera")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:01eac0f064a1c2e49b31de4270358e4d677291b3f639104cb7b491334a8fe9a3
3+
size 196608
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:6a2a5eb638ecfbc462f226e3fc1885c2536708b3a7acbf4ee0bfe6460a31ec4b
3+
size 40960
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:1e0d3edf4928f1db9cefb0ef70fe6d652f6fc189ca34cf255681951fb87e4db5
3+
size 36864
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:7304bd6e1c3b851dd1652af259d1a3755527395e498e9c7a4c1cf5589db8be27
3+
size 212960
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
from flow.record.fieldtypes import datetime as dt
7+
8+
from dissect.target.plugins.apps.browser.opera import OperaPlugin
9+
from tests._utils import absolute_path
10+
11+
if TYPE_CHECKING:
12+
from dissect.target.filesystem import VirtualFilesystem
13+
from dissect.target.target import Target
14+
15+
16+
@pytest.fixture
17+
def target_opera_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Target:
18+
fs_win.map_dir(
19+
"Users\\John\\AppData\\Roaming\\Opera Software\\Opera Stable\\Default\\",
20+
absolute_path("_data/plugins/apps/browser/opera/"),
21+
)
22+
fs_win.map_dir(
23+
(
24+
"Users\\John\\AppData\\Roaming\\Opera Software\\Opera Stable"
25+
"\\_side_profiles\\31303232385F31323239343834393136\\Default"
26+
),
27+
absolute_path("_data/plugins/apps/browser/opera/"),
28+
)
29+
30+
target_win_users.add_plugin(OperaPlugin)
31+
return target_win_users
32+
33+
34+
@pytest.fixture
35+
def target_operagx_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Target:
36+
fs_win.map_dir(
37+
"Users\\John\\AppData\\Roaming\\Opera Software\\Opera GX Stable\\Default\\",
38+
absolute_path("_data/plugins/apps/browser/opera/"),
39+
)
40+
fs_win.map_dir(
41+
(
42+
"Users\\John\\AppData\\Roaming\\Opera Software\\Opera GX Stable"
43+
"\\_side_profiles\\31303232385F31323239343834393136\\Default\\"
44+
),
45+
absolute_path("_data/plugins/apps/browser/opera/"),
46+
)
47+
48+
target_win_users.add_plugin(OperaPlugin)
49+
return target_win_users
50+
51+
52+
@pytest.mark.parametrize(
53+
"target_platform",
54+
["target_opera_win", "target_operagx_win"],
55+
)
56+
def test_opera_history(target_platform: Target, request: pytest.FixtureRequest) -> None:
57+
target_platform = request.getfixturevalue(target_platform)
58+
records = list(target_platform.opera.history())
59+
60+
assert len(records) == 164
61+
assert {"opera"} == {record.browser for record in records}
62+
63+
assert records[75].url == "https://github.com/fox-it/dissect.target"
64+
assert records[75].id == 76
65+
assert records[75].visit_count == 2
66+
assert records[75].ts == dt("2026-03-17 13:24:54.736168+00:00")
67+
68+
69+
@pytest.mark.parametrize(
70+
"target_platform",
71+
["target_opera_win", "target_operagx_win"],
72+
)
73+
def test_opera_cookies(target_platform: Target, request: pytest.FixtureRequest) -> None:
74+
target_platform = request.getfixturevalue(target_platform)
75+
records = list(target_platform.opera.cookies())
76+
77+
assert len(records) == 102
78+
assert {"opera"} == {record.browser for record in records}
79+
80+
assert records[-1].host == ".github.com"
81+
assert records[-1].name == "tz"
82+
83+
84+
@pytest.mark.parametrize(
85+
"target_platform",
86+
["target_opera_win", "target_operagx_win"],
87+
)
88+
def test_opera_downloads(target_platform: Target, request: pytest.FixtureRequest) -> None:
89+
target_platform = request.getfixturevalue(target_platform)
90+
records = list(target_platform.opera.downloads())
91+
92+
assert len(records) == 2
93+
assert {"opera"} == {record.browser for record in records}
94+
95+
assert records[0].url == "https://codeload.github.com/fox-it/dissect.target/zip/refs/tags/3.25.1"
96+
assert records[0].tab_url == "https://github.com/fox-it/dissect.target/releases/tag/3.25.1"
97+
assert records[0].state == "complete"
98+
assert records[0].mime_type == "application/x-zip-compressed"
99+
assert records[0].path == "C:\\Users\\User\\Downloads\\dissect.target-3.25.1.zip"
100+
101+
102+
@pytest.mark.parametrize(
103+
"target_platform",
104+
["target_opera_win", "target_operagx_win"],
105+
)
106+
def test_opera_extensions(target_platform: Target, request: pytest.FixtureRequest) -> None:
107+
target_platform = request.getfixturevalue(target_platform)
108+
records = list(target_platform.opera.extensions())
109+
110+
assert len(records) == 66
111+
assert {"opera"} == {record.browser for record in records}
112+
113+
assert records[0].name == "Web Store"
114+
assert not records[0].blacklisted
115+
assert records[0].extension_id == "ahfgeienlihckogmohjhadlkjgocpleb"
116+
assert records[0].description == "Discover great apps, games, extensions and themes for Opera."
117+
assert records[0].version == "0.2"
118+
119+
assert records[65].name == "NewTab. Search"
120+
assert records[65].blacklisted
121+
assert records[65].extension_id == "pookachmhghnpgjhebhilcidgdphdlhi"
122+
assert records[65].description == "The extension changes default search provider."
123+
assert records[65].version == "2.0.0.0"

0 commit comments

Comments
 (0)