extrospect 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extrospect/__init__.py +3 -0
- extrospect/__main__.py +3 -0
- extrospect/acquire/__init__.py +1 -0
- extrospect/acquire/crx_download.py +88 -0
- extrospect/acquire/local_profile.py +285 -0
- extrospect/acquire/unpack.py +199 -0
- extrospect/baseline/__init__.py +1 -0
- extrospect/baseline/gbm.py +138 -0
- extrospect/baseline/rules.py +103 -0
- extrospect/cache.py +216 -0
- extrospect/cli.py +873 -0
- extrospect/config.py +387 -0
- extrospect/data/__init__.py +1 -0
- extrospect/data/advisories_builtin.json +287 -0
- extrospect/data/intel_builtin.jsonl +25 -0
- extrospect/data/lib_hashes.json +3 -0
- extrospect/evaluation.py +386 -0
- extrospect/fuse.py +140 -0
- extrospect/http.py +98 -0
- extrospect/i18n/__init__.py +266 -0
- extrospect/i18n/locales/en.json +374 -0
- extrospect/i18n/locales/zh-CN.json +374 -0
- extrospect/knowledge/__init__.py +1 -0
- extrospect/knowledge/advisory.py +149 -0
- extrospect/knowledge/domains.py +110 -0
- extrospect/knowledge/embeddings.py +128 -0
- extrospect/knowledge/intel.py +156 -0
- extrospect/knowledge/listing.py +264 -0
- extrospect/knowledge/store.py +282 -0
- extrospect/llm/__init__.py +2 -0
- extrospect/llm/client.py +437 -0
- extrospect/llm/engine.py +233 -0
- extrospect/llm/fake.py +261 -0
- extrospect/llm/investigator.py +231 -0
- extrospect/llm/prompts/__init__.py +44 -0
- extrospect/llm/prompts/host_audit_v1.md +16 -0
- extrospect/llm/prompts/investigator_v1.md +24 -0
- extrospect/llm/prompts/listing_extract_v1.md +8 -0
- extrospect/llm/prompts/triage_v1.md +24 -0
- extrospect/llm/prompts/verifier_v1.md +7 -0
- extrospect/llm/router.py +170 -0
- extrospect/llm/sanitize.py +322 -0
- extrospect/llm/tools.py +646 -0
- extrospect/llm/triage.py +326 -0
- extrospect/llm/verifier.py +228 -0
- extrospect/models.py +497 -0
- extrospect/pipeline.py +375 -0
- extrospect/report.py +736 -0
- extrospect/static/__init__.py +1 -0
- extrospect/static/analyzer.py +221 -0
- extrospect/static/csp.py +114 -0
- extrospect/static/deobfuscate.py +177 -0
- extrospect/static/features.py +239 -0
- extrospect/static/jsparse.py +471 -0
- extrospect/static/libstrip.py +132 -0
- extrospect/static/manifest.py +457 -0
- extrospect/static/nodetools.py +61 -0
- extrospect/static/permissions.py +402 -0
- extrospect/static/sinks.py +1081 -0
- extrospect/static/slicer.py +303 -0
- extrospect/static/surface.py +398 -0
- extrospect/static/vulns.py +416 -0
- extrospect/trace.py +190 -0
- extrospect/util.py +224 -0
- extrospect/workspace.py +342 -0
- extrospect-0.1.0.dist-info/METADATA +244 -0
- extrospect-0.1.0.dist-info/RECORD +73 -0
- extrospect-0.1.0.dist-info/WHEEL +4 -0
- extrospect-0.1.0.dist-info/entry_points.txt +3 -0
- extrospect-0.1.0.dist-info/licenses/LICENSE +202 -0
- extrospect-0.1.0.dist-info/licenses/NOTICE +4 -0
- extrospect_mcp/__init__.py +1 -0
- extrospect_mcp/server.py +550 -0
extrospect/__init__.py
ADDED
extrospect/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Acquiring extensions: local browser profiles, Chrome Web Store downloads, CRX unpacking."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Download the current CRX of an extension from Chrome's public update endpoint.
|
|
2
|
+
|
|
3
|
+
Only the *latest* published version is available; removed extensions cannot be
|
|
4
|
+
downloaded this way (the endpoint answers 204/404) - the evaluation set needs other
|
|
5
|
+
sources for those. Requests are rate-limited (1/s by default) and identify the client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from extrospect.acquire.unpack import CRX_MAGIC, UnpackError, parse_crx_header
|
|
15
|
+
from extrospect.config import Settings
|
|
16
|
+
from extrospect.http import HttpClient
|
|
17
|
+
from extrospect.models import EXTENSION_ID_RE
|
|
18
|
+
from extrospect.util import sha256_bytes
|
|
19
|
+
|
|
20
|
+
UPDATE_ENDPOINT = "https://clients2.google.com/service/update2/crx"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExtensionUnavailable(DownloadError):
|
|
28
|
+
"""The store does not serve this id (removed, private, or wrong id)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DownloadResult:
|
|
33
|
+
ext_id: str
|
|
34
|
+
path: Path
|
|
35
|
+
sha256: str
|
|
36
|
+
size: int
|
|
37
|
+
crx_format: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def download_url(ext_id: str, prodversion: str) -> str:
|
|
41
|
+
x = quote(f"id={ext_id}&uc", safe="")
|
|
42
|
+
return (
|
|
43
|
+
f"{UPDATE_ENDPOINT}?response=redirect&prodversion={prodversion}"
|
|
44
|
+
f"&acceptformat=crx2,crx3&x={x}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def download_crx(
|
|
49
|
+
ext_id: str, settings: Settings, http: HttpClient, dest_dir: Path | None = None
|
|
50
|
+
) -> DownloadResult:
|
|
51
|
+
ext_id = ext_id.strip().lower()
|
|
52
|
+
if not EXTENSION_ID_RE.match(ext_id):
|
|
53
|
+
raise DownloadError(f"invalid extension id: {ext_id!r} (expected 32 letters a-p)")
|
|
54
|
+
url = download_url(ext_id, settings.network.crx_prodversion)
|
|
55
|
+
response = http.get(
|
|
56
|
+
url,
|
|
57
|
+
rate_key="chrome-webstore",
|
|
58
|
+
per_second=settings.network.webstore_requests_per_s,
|
|
59
|
+
timeout=max(60.0, settings.network.timeout_s),
|
|
60
|
+
)
|
|
61
|
+
if response.status_code in {204, 404}:
|
|
62
|
+
raise ExtensionUnavailable(f"{ext_id} is not available from the Chrome Web Store")
|
|
63
|
+
if response.status_code >= 400:
|
|
64
|
+
raise DownloadError(f"download failed with HTTP {response.status_code}")
|
|
65
|
+
data = response.content
|
|
66
|
+
if not data.startswith(CRX_MAGIC):
|
|
67
|
+
raise ExtensionUnavailable(f"{ext_id}: the update server did not return a CRX file")
|
|
68
|
+
try:
|
|
69
|
+
info = parse_crx_header(data)
|
|
70
|
+
except UnpackError as exc:
|
|
71
|
+
raise DownloadError(f"{ext_id}: corrupt CRX ({exc})") from exc
|
|
72
|
+
target_dir = dest_dir or settings.paths.downloads_dir
|
|
73
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
digest = sha256_bytes(data)
|
|
75
|
+
path = target_dir / f"{ext_id}_{digest[:12]}.crx"
|
|
76
|
+
path.write_bytes(data)
|
|
77
|
+
return DownloadResult(
|
|
78
|
+
ext_id=ext_id, path=path, sha256=digest, size=len(data), crx_format=info.format
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"DownloadError",
|
|
84
|
+
"DownloadResult",
|
|
85
|
+
"ExtensionUnavailable",
|
|
86
|
+
"download_crx",
|
|
87
|
+
"download_url",
|
|
88
|
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Discover extensions installed in local Chromium-family browser profiles.
|
|
2
|
+
|
|
3
|
+
Reads ``Local State`` (profile list), ``Secure Preferences`` / ``Preferences``
|
|
4
|
+
(``extensions.settings``: enabled state, install location, unpacked paths) and scans
|
|
5
|
+
``<profile>/Extensions/<id>/<version>_0/``. Nothing is sent anywhere.
|
|
6
|
+
|
|
7
|
+
Preference field names differ across browser versions (``state`` vs
|
|
8
|
+
``disable_reasons``); both are handled. Set ``EXTROSPECT_BROWSER_ROOTS`` to
|
|
9
|
+
``name=path`` entries (``os.pathsep``-separated) to scan custom locations.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from extrospect.models import EXTENSION_ID_RE, ExtensionRef, InstalledExtension
|
|
23
|
+
from extrospect.static.manifest import load_json_file, summarize_manifest
|
|
24
|
+
|
|
25
|
+
_LINUX = {
|
|
26
|
+
"chrome": [
|
|
27
|
+
"~/.config/google-chrome",
|
|
28
|
+
"~/.config/google-chrome-beta",
|
|
29
|
+
"~/.config/google-chrome-unstable",
|
|
30
|
+
"~/.var/app/com.google.Chrome/config/google-chrome",
|
|
31
|
+
],
|
|
32
|
+
"chromium": [
|
|
33
|
+
"~/.config/chromium",
|
|
34
|
+
"~/snap/chromium/common/chromium",
|
|
35
|
+
"~/.var/app/org.chromium.Chromium/config/chromium",
|
|
36
|
+
],
|
|
37
|
+
"edge": [
|
|
38
|
+
"~/.config/microsoft-edge",
|
|
39
|
+
"~/.config/microsoft-edge-beta",
|
|
40
|
+
"~/.config/microsoft-edge-dev",
|
|
41
|
+
"~/.var/app/com.microsoft.Edge/config/microsoft-edge",
|
|
42
|
+
],
|
|
43
|
+
"brave": [
|
|
44
|
+
"~/.config/BraveSoftware/Brave-Browser",
|
|
45
|
+
"~/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser",
|
|
46
|
+
],
|
|
47
|
+
"vivaldi": ["~/.config/vivaldi"],
|
|
48
|
+
}
|
|
49
|
+
_MACOS = {
|
|
50
|
+
"chrome": [
|
|
51
|
+
"~/Library/Application Support/Google/Chrome",
|
|
52
|
+
"~/Library/Application Support/Google/Chrome Beta",
|
|
53
|
+
"~/Library/Application Support/Google/Chrome Canary",
|
|
54
|
+
],
|
|
55
|
+
"chromium": ["~/Library/Application Support/Chromium"],
|
|
56
|
+
"edge": ["~/Library/Application Support/Microsoft Edge"],
|
|
57
|
+
"brave": ["~/Library/Application Support/BraveSoftware/Brave-Browser"],
|
|
58
|
+
"vivaldi": ["~/Library/Application Support/Vivaldi"],
|
|
59
|
+
}
|
|
60
|
+
_WINDOWS = {
|
|
61
|
+
"chrome": [
|
|
62
|
+
"%LOCALAPPDATA%/Google/Chrome/User Data",
|
|
63
|
+
"%LOCALAPPDATA%/Google/Chrome Beta/User Data",
|
|
64
|
+
"%LOCALAPPDATA%/Google/Chrome SxS/User Data",
|
|
65
|
+
],
|
|
66
|
+
"chromium": ["%LOCALAPPDATA%/Chromium/User Data"],
|
|
67
|
+
"edge": ["%LOCALAPPDATA%/Microsoft/Edge/User Data"],
|
|
68
|
+
"brave": ["%LOCALAPPDATA%/BraveSoftware/Brave-Browser/User Data"],
|
|
69
|
+
"vivaldi": ["%LOCALAPPDATA%/Vivaldi/User Data"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Manifest::Location enum (chrome/common/extensions/manifest.h)
|
|
73
|
+
LOCATIONS = {
|
|
74
|
+
1: "webstore", # INTERNAL: installed from a CRX (normally the Web Store)
|
|
75
|
+
2: "external",
|
|
76
|
+
3: "external",
|
|
77
|
+
4: "unpacked",
|
|
78
|
+
5: "component",
|
|
79
|
+
6: "external",
|
|
80
|
+
7: "policy",
|
|
81
|
+
8: "command_line",
|
|
82
|
+
9: "policy",
|
|
83
|
+
10: "component",
|
|
84
|
+
}
|
|
85
|
+
# Built-in component extensions that are not user-installed.
|
|
86
|
+
COMPONENT_IDS = {
|
|
87
|
+
"nmmhkkegccagdldgiimedpiccmgmieda", # Chrome Web Store Payments
|
|
88
|
+
"pkedcjkdefgpdelpbcmbmeomcjbeemfm", # Chrome Media Router
|
|
89
|
+
"mhjfbmdgcfjbbpaeojofohoefgiehjai", # Chrome PDF Viewer
|
|
90
|
+
"ahfgeienlihckogmohjhadlkjgocpleb", # Web Store app
|
|
91
|
+
"kmendfapggjehodndflmmgagdbamhnfd", # CryptoTokenExtension
|
|
92
|
+
"neajdppkdcdipfabeoofebfddakdcjhd", # Google Network Speech
|
|
93
|
+
"nkeimhogjdpnpccoofpliimaahmaaome", # Google Hangouts
|
|
94
|
+
}
|
|
95
|
+
_VERSION_DIR_RE = re.compile(r"^(\d+(?:\.\d+)*)(?:_\d+)?$")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class BrowserRoot:
|
|
100
|
+
browser: str
|
|
101
|
+
path: Path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def default_roots() -> list[BrowserRoot]:
|
|
105
|
+
env = os.environ.get("EXTROSPECT_BROWSER_ROOTS")
|
|
106
|
+
if env:
|
|
107
|
+
roots = []
|
|
108
|
+
for item in env.split(os.pathsep):
|
|
109
|
+
if not item.strip():
|
|
110
|
+
continue
|
|
111
|
+
name, sep, path = item.partition("=")
|
|
112
|
+
if not sep:
|
|
113
|
+
name, path = "chromium", item
|
|
114
|
+
roots.append(BrowserRoot(name.strip() or "chromium", Path(path).expanduser()))
|
|
115
|
+
return roots
|
|
116
|
+
table = _WINDOWS if sys.platform == "win32" else _MACOS if sys.platform == "darwin" else _LINUX
|
|
117
|
+
roots = []
|
|
118
|
+
for browser, paths in table.items():
|
|
119
|
+
for raw in paths:
|
|
120
|
+
expanded = Path(os.path.expandvars(os.path.expanduser(raw)))
|
|
121
|
+
if "%" not in str(expanded) and expanded.is_dir():
|
|
122
|
+
roots.append(BrowserRoot(browser, expanded))
|
|
123
|
+
return roots
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
127
|
+
try:
|
|
128
|
+
data = load_json_file(path)
|
|
129
|
+
except (OSError, ValueError, UnicodeError):
|
|
130
|
+
return {}
|
|
131
|
+
return data if isinstance(data, dict) else {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _profiles(root: Path) -> list[tuple[str, str | None]]:
|
|
135
|
+
"""(profile directory, display name) pairs."""
|
|
136
|
+
local_state = _read_json(root / "Local State")
|
|
137
|
+
cache = (local_state.get("profile") or {}).get("info_cache") or {}
|
|
138
|
+
profiles: dict[str, str | None] = {}
|
|
139
|
+
if isinstance(cache, dict):
|
|
140
|
+
for directory, info in cache.items():
|
|
141
|
+
if (root / directory).is_dir():
|
|
142
|
+
profiles[directory] = (info or {}).get("name") if isinstance(info, dict) else None
|
|
143
|
+
for child in sorted(root.iterdir()) if root.is_dir() else []:
|
|
144
|
+
if child.is_dir() and (child.name == "Default" or child.name.startswith("Profile ")):
|
|
145
|
+
profiles.setdefault(child.name, None)
|
|
146
|
+
return sorted(profiles.items(), key=lambda kv: (kv[0] != "Default", kv[0]))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extension_settings(profile_dir: Path) -> dict[str, dict[str, Any]]:
|
|
150
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
151
|
+
for name in ("Preferences", "Secure Preferences"):
|
|
152
|
+
prefs = _read_json(profile_dir / name)
|
|
153
|
+
settings = (prefs.get("extensions") or {}).get("settings") or {}
|
|
154
|
+
if isinstance(settings, dict):
|
|
155
|
+
for ext_id, entry in settings.items():
|
|
156
|
+
if isinstance(entry, dict):
|
|
157
|
+
merged.setdefault(ext_id, {}).update(entry)
|
|
158
|
+
return merged
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _version_key(name: str) -> tuple[int, ...]:
|
|
162
|
+
match = _VERSION_DIR_RE.match(name)
|
|
163
|
+
if not match:
|
|
164
|
+
return (-1,)
|
|
165
|
+
return tuple(int(p) for p in match.group(1).split("."))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _installed_dir(profile_dir: Path, ext_id: str, settings: dict[str, Any]) -> Path | None:
|
|
169
|
+
location = settings.get("location")
|
|
170
|
+
path_value = settings.get("path")
|
|
171
|
+
if isinstance(path_value, str) and path_value:
|
|
172
|
+
candidate = Path(path_value)
|
|
173
|
+
if location == 4 or candidate.is_absolute():
|
|
174
|
+
return candidate if (candidate / "manifest.json").is_file() else None
|
|
175
|
+
candidate = profile_dir / "Extensions" / path_value
|
|
176
|
+
if (candidate / "manifest.json").is_file():
|
|
177
|
+
return candidate
|
|
178
|
+
ext_root = profile_dir / "Extensions" / ext_id
|
|
179
|
+
if not ext_root.is_dir():
|
|
180
|
+
return None
|
|
181
|
+
versions = [d for d in ext_root.iterdir() if d.is_dir() and (d / "manifest.json").is_file()]
|
|
182
|
+
if not versions:
|
|
183
|
+
return None
|
|
184
|
+
return max(versions, key=lambda d: _version_key(d.name))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _enabled(settings: dict[str, Any]) -> bool | None:
|
|
188
|
+
if "disable_reasons" in settings:
|
|
189
|
+
reasons = settings.get("disable_reasons")
|
|
190
|
+
if isinstance(reasons, list):
|
|
191
|
+
return len(reasons) == 0
|
|
192
|
+
if isinstance(reasons, int):
|
|
193
|
+
return reasons == 0
|
|
194
|
+
if "state" in settings:
|
|
195
|
+
return settings.get("state") == 1
|
|
196
|
+
return None if not settings else True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def discover_extensions(
|
|
200
|
+
roots: Iterable[BrowserRoot] | None = None,
|
|
201
|
+
include_components: bool = False,
|
|
202
|
+
ui_language: str | None = None,
|
|
203
|
+
) -> list[InstalledExtension]:
|
|
204
|
+
found: list[InstalledExtension] = []
|
|
205
|
+
for root in roots if roots is not None else default_roots():
|
|
206
|
+
if not root.path.is_dir():
|
|
207
|
+
continue
|
|
208
|
+
for profile, profile_name in _profiles(root.path):
|
|
209
|
+
profile_dir = root.path / profile
|
|
210
|
+
settings_map = _extension_settings(profile_dir)
|
|
211
|
+
ids = set(settings_map)
|
|
212
|
+
ext_dir_root = profile_dir / "Extensions"
|
|
213
|
+
if ext_dir_root.is_dir():
|
|
214
|
+
ids |= {
|
|
215
|
+
d.name
|
|
216
|
+
for d in ext_dir_root.iterdir()
|
|
217
|
+
if d.is_dir() and EXTENSION_ID_RE.match(d.name)
|
|
218
|
+
}
|
|
219
|
+
for ext_id in sorted(ids):
|
|
220
|
+
settings = settings_map.get(ext_id, {})
|
|
221
|
+
location_code = settings.get("location")
|
|
222
|
+
location = LOCATIONS.get(location_code) if isinstance(location_code, int) else None
|
|
223
|
+
is_component = location == "component" or ext_id in COMPONENT_IDS
|
|
224
|
+
if is_component and not include_components:
|
|
225
|
+
continue
|
|
226
|
+
directory = _installed_dir(profile_dir, ext_id, settings)
|
|
227
|
+
if directory is None:
|
|
228
|
+
continue
|
|
229
|
+
raw = _read_json(directory / "manifest.json")
|
|
230
|
+
if not raw:
|
|
231
|
+
continue
|
|
232
|
+
summary = summarize_manifest(directory, raw, ui_language)
|
|
233
|
+
if (
|
|
234
|
+
raw.get("theme") is not None
|
|
235
|
+
and not raw.get("background")
|
|
236
|
+
and not raw.get("content_scripts")
|
|
237
|
+
):
|
|
238
|
+
location = location or "theme"
|
|
239
|
+
found.append(
|
|
240
|
+
InstalledExtension(
|
|
241
|
+
ref=ExtensionRef(
|
|
242
|
+
id=ext_id,
|
|
243
|
+
version=summary.version or None,
|
|
244
|
+
source="local",
|
|
245
|
+
path=directory,
|
|
246
|
+
browser=root.browser,
|
|
247
|
+
profile=profile,
|
|
248
|
+
),
|
|
249
|
+
name=summary.name,
|
|
250
|
+
enabled=_enabled(settings),
|
|
251
|
+
install_location=location
|
|
252
|
+
or ("unpacked" if directory.parent.name != ext_id else None),
|
|
253
|
+
from_webstore=settings.get("from_webstore")
|
|
254
|
+
if isinstance(settings.get("from_webstore"), bool)
|
|
255
|
+
else None,
|
|
256
|
+
profile_name=profile_name,
|
|
257
|
+
manifest_version=summary.manifest_version,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
found.sort(key=lambda e: (e.ref.browser or "", e.ref.profile or "", e.name.lower()))
|
|
261
|
+
return found
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def find_installed(
|
|
265
|
+
ext_id: str, profile: str | None = None, browser: str | None = None
|
|
266
|
+
) -> InstalledExtension | None:
|
|
267
|
+
for ext in discover_extensions(include_components=True):
|
|
268
|
+
if ext.ref.id != ext_id:
|
|
269
|
+
continue
|
|
270
|
+
if profile and ext.ref.profile != profile:
|
|
271
|
+
continue
|
|
272
|
+
if browser and ext.ref.browser != browser:
|
|
273
|
+
continue
|
|
274
|
+
return ext
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = [
|
|
279
|
+
"COMPONENT_IDS",
|
|
280
|
+
"LOCATIONS",
|
|
281
|
+
"BrowserRoot",
|
|
282
|
+
"default_roots",
|
|
283
|
+
"discover_extensions",
|
|
284
|
+
"find_installed",
|
|
285
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""CRX2/CRX3 parsing and safe extraction (zip-slip and zip-bomb protection).
|
|
2
|
+
|
|
3
|
+
CRX3 layout: ``"Cr24"`` | uint32 LE version (3) | uint32 LE header length | protobuf
|
|
4
|
+
``CrxFileHeader`` | ZIP. The extension id is the ``crx_id`` inside the signed header data
|
|
5
|
+
(first 16 bytes of the SHA-256 of the publisher key), mapped to ``a``-``p``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import struct
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from extrospect.static.manifest import id_from_bytes, id_from_public_key
|
|
19
|
+
from extrospect.util import PathEscapeError, safe_join
|
|
20
|
+
|
|
21
|
+
CRX_MAGIC = b"Cr24"
|
|
22
|
+
ZIP_MAGIC = b"PK\x03\x04"
|
|
23
|
+
# Real extensions reach several hundred MB unpacked (ad-block filter lists, WASM models).
|
|
24
|
+
DEFAULT_MAX_BYTES = 1024 * 1024 * 1024
|
|
25
|
+
DEFAULT_MAX_FILES = 20_000
|
|
26
|
+
# Zip-bomb guard: a large member that expands far beyond its compressed size.
|
|
27
|
+
BOMB_MIN_BYTES = 64 * 1024 * 1024
|
|
28
|
+
BOMB_RATIO = 200
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnpackError(ValueError):
|
|
32
|
+
"""The package is malformed, unsafe or too large."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CrxInfo:
|
|
37
|
+
format: str # "crx3" / "crx2" / "zip"
|
|
38
|
+
zip_offset: int
|
|
39
|
+
extension_id: str | None = None
|
|
40
|
+
public_key: bytes | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_varint(data: bytes, pos: int) -> tuple[int, int]:
|
|
44
|
+
result = shift = 0
|
|
45
|
+
while True:
|
|
46
|
+
if pos >= len(data):
|
|
47
|
+
raise UnpackError("truncated varint in CRX header")
|
|
48
|
+
byte = data[pos]
|
|
49
|
+
pos += 1
|
|
50
|
+
result |= (byte & 0x7F) << shift
|
|
51
|
+
if not byte & 0x80:
|
|
52
|
+
return result, pos
|
|
53
|
+
shift += 7
|
|
54
|
+
if shift > 63:
|
|
55
|
+
raise UnpackError("varint too long in CRX header")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_protobuf(data: bytes) -> dict[int, list[bytes | int]]:
|
|
59
|
+
"""Minimal protobuf wire-format reader (enough for CrxFileHeader / SignedData)."""
|
|
60
|
+
fields: dict[int, list[bytes | int]] = {}
|
|
61
|
+
pos = 0
|
|
62
|
+
while pos < len(data):
|
|
63
|
+
key, pos = _read_varint(data, pos)
|
|
64
|
+
number, wire_type = key >> 3, key & 7
|
|
65
|
+
if wire_type == 0:
|
|
66
|
+
value, pos = _read_varint(data, pos)
|
|
67
|
+
fields.setdefault(number, []).append(value)
|
|
68
|
+
elif wire_type == 2:
|
|
69
|
+
length, pos = _read_varint(data, pos)
|
|
70
|
+
if pos + length > len(data):
|
|
71
|
+
raise UnpackError("truncated field in CRX header")
|
|
72
|
+
fields.setdefault(number, []).append(data[pos : pos + length])
|
|
73
|
+
pos += length
|
|
74
|
+
elif wire_type == 1:
|
|
75
|
+
pos += 8
|
|
76
|
+
elif wire_type == 5:
|
|
77
|
+
pos += 4
|
|
78
|
+
else:
|
|
79
|
+
raise UnpackError(f"unsupported protobuf wire type {wire_type}")
|
|
80
|
+
return fields
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_crx_header(data: bytes) -> CrxInfo:
|
|
84
|
+
if data.startswith((ZIP_MAGIC, b"PK")):
|
|
85
|
+
return CrxInfo(format="zip", zip_offset=0)
|
|
86
|
+
if not data.startswith(CRX_MAGIC) or len(data) < 12:
|
|
87
|
+
raise UnpackError("not a CRX or ZIP file")
|
|
88
|
+
version = struct.unpack_from("<I", data, 4)[0]
|
|
89
|
+
if version == 3:
|
|
90
|
+
header_len = struct.unpack_from("<I", data, 8)[0]
|
|
91
|
+
header_end = 12 + header_len
|
|
92
|
+
if header_end > len(data):
|
|
93
|
+
raise UnpackError("CRX3 header exceeds file size")
|
|
94
|
+
header = parse_protobuf(data[12:header_end])
|
|
95
|
+
info = CrxInfo(format="crx3", zip_offset=header_end)
|
|
96
|
+
signed = header.get(10000)
|
|
97
|
+
if signed and isinstance(signed[0], bytes):
|
|
98
|
+
crx_id = parse_protobuf(signed[0]).get(1)
|
|
99
|
+
if crx_id and isinstance(crx_id[0], bytes) and len(crx_id[0]) >= 16:
|
|
100
|
+
info.extension_id = id_from_bytes(crx_id[0])
|
|
101
|
+
for proof_field in (2, 3): # sha256_with_rsa, sha256_with_ecdsa
|
|
102
|
+
for proof in header.get(proof_field, []):
|
|
103
|
+
if isinstance(proof, bytes):
|
|
104
|
+
key = parse_protobuf(proof).get(1)
|
|
105
|
+
if key and isinstance(key[0], bytes):
|
|
106
|
+
info.public_key = info.public_key or key[0]
|
|
107
|
+
if info.extension_id is None and info.public_key:
|
|
108
|
+
info.extension_id = id_from_public_key(info.public_key)
|
|
109
|
+
return info
|
|
110
|
+
if version == 2:
|
|
111
|
+
key_len, sig_len = struct.unpack_from("<II", data, 8)
|
|
112
|
+
start = 16
|
|
113
|
+
public_key = data[start : start + key_len]
|
|
114
|
+
return CrxInfo(
|
|
115
|
+
format="crx2",
|
|
116
|
+
zip_offset=start + key_len + sig_len,
|
|
117
|
+
public_key=public_key,
|
|
118
|
+
extension_id=id_from_public_key(public_key) if public_key else None,
|
|
119
|
+
)
|
|
120
|
+
raise UnpackError(f"unsupported CRX version {version}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_zip(
|
|
124
|
+
zip_bytes: bytes,
|
|
125
|
+
dest: Path,
|
|
126
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
127
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
128
|
+
) -> int:
|
|
129
|
+
"""Extract safely; returns the number of files written."""
|
|
130
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
total = 0
|
|
132
|
+
count = 0
|
|
133
|
+
try:
|
|
134
|
+
archive = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
|
135
|
+
except zipfile.BadZipFile as exc:
|
|
136
|
+
raise UnpackError(f"invalid zip archive: {exc}") from exc
|
|
137
|
+
with archive:
|
|
138
|
+
for info in archive.infolist():
|
|
139
|
+
name = info.filename.replace("\\", "/")
|
|
140
|
+
if info.is_dir() or name.endswith("/"):
|
|
141
|
+
continue
|
|
142
|
+
mode = info.external_attr >> 16
|
|
143
|
+
if stat.S_ISLNK(mode):
|
|
144
|
+
continue # never materialise symlinks from an archive
|
|
145
|
+
try:
|
|
146
|
+
target = safe_join(dest, name)
|
|
147
|
+
except PathEscapeError as exc:
|
|
148
|
+
raise UnpackError(f"unsafe path in archive: {name}") from exc
|
|
149
|
+
count += 1
|
|
150
|
+
if count > max_files:
|
|
151
|
+
raise UnpackError(f"archive has more than {max_files} files")
|
|
152
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
written = 0
|
|
154
|
+
with archive.open(info) as src, target.open("wb") as dst:
|
|
155
|
+
while chunk := src.read(1 << 16):
|
|
156
|
+
total += len(chunk)
|
|
157
|
+
written += len(chunk)
|
|
158
|
+
if total > max_bytes:
|
|
159
|
+
raise UnpackError(f"archive expands beyond {max_bytes} bytes")
|
|
160
|
+
if written > BOMB_MIN_BYTES and written > BOMB_RATIO * max(
|
|
161
|
+
info.compress_size, 1
|
|
162
|
+
):
|
|
163
|
+
raise UnpackError(f"suspicious compression ratio in {name}")
|
|
164
|
+
dst.write(chunk)
|
|
165
|
+
return count
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def unpack_package(
|
|
169
|
+
data: bytes,
|
|
170
|
+
dest: Path,
|
|
171
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
172
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
173
|
+
) -> CrxInfo:
|
|
174
|
+
"""Unpack a CRX/ZIP into ``dest`` (replacing it). Returns header information."""
|
|
175
|
+
info = parse_crx_header(data)
|
|
176
|
+
if dest.exists():
|
|
177
|
+
shutil.rmtree(dest)
|
|
178
|
+
try:
|
|
179
|
+
extract_zip(data[info.zip_offset :], dest, max_bytes=max_bytes, max_files=max_files)
|
|
180
|
+
except UnpackError:
|
|
181
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
182
|
+
raise
|
|
183
|
+
return info
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def unpack_file(path: Path, dest: Path, **kwargs: int) -> CrxInfo:
|
|
187
|
+
return unpack_package(path.read_bytes(), dest, **kwargs)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
__all__ = [
|
|
191
|
+
"CRX_MAGIC",
|
|
192
|
+
"CrxInfo",
|
|
193
|
+
"UnpackError",
|
|
194
|
+
"extract_zip",
|
|
195
|
+
"parse_crx_header",
|
|
196
|
+
"parse_protobuf",
|
|
197
|
+
"unpack_file",
|
|
198
|
+
"unpack_package",
|
|
199
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Non-LLM baselines: the deterministic rule score and a LightGBM model."""
|