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.
Files changed (73) hide show
  1. extrospect/__init__.py +3 -0
  2. extrospect/__main__.py +3 -0
  3. extrospect/acquire/__init__.py +1 -0
  4. extrospect/acquire/crx_download.py +88 -0
  5. extrospect/acquire/local_profile.py +285 -0
  6. extrospect/acquire/unpack.py +199 -0
  7. extrospect/baseline/__init__.py +1 -0
  8. extrospect/baseline/gbm.py +138 -0
  9. extrospect/baseline/rules.py +103 -0
  10. extrospect/cache.py +216 -0
  11. extrospect/cli.py +873 -0
  12. extrospect/config.py +387 -0
  13. extrospect/data/__init__.py +1 -0
  14. extrospect/data/advisories_builtin.json +287 -0
  15. extrospect/data/intel_builtin.jsonl +25 -0
  16. extrospect/data/lib_hashes.json +3 -0
  17. extrospect/evaluation.py +386 -0
  18. extrospect/fuse.py +140 -0
  19. extrospect/http.py +98 -0
  20. extrospect/i18n/__init__.py +266 -0
  21. extrospect/i18n/locales/en.json +374 -0
  22. extrospect/i18n/locales/zh-CN.json +374 -0
  23. extrospect/knowledge/__init__.py +1 -0
  24. extrospect/knowledge/advisory.py +149 -0
  25. extrospect/knowledge/domains.py +110 -0
  26. extrospect/knowledge/embeddings.py +128 -0
  27. extrospect/knowledge/intel.py +156 -0
  28. extrospect/knowledge/listing.py +264 -0
  29. extrospect/knowledge/store.py +282 -0
  30. extrospect/llm/__init__.py +2 -0
  31. extrospect/llm/client.py +437 -0
  32. extrospect/llm/engine.py +233 -0
  33. extrospect/llm/fake.py +261 -0
  34. extrospect/llm/investigator.py +231 -0
  35. extrospect/llm/prompts/__init__.py +44 -0
  36. extrospect/llm/prompts/host_audit_v1.md +16 -0
  37. extrospect/llm/prompts/investigator_v1.md +24 -0
  38. extrospect/llm/prompts/listing_extract_v1.md +8 -0
  39. extrospect/llm/prompts/triage_v1.md +24 -0
  40. extrospect/llm/prompts/verifier_v1.md +7 -0
  41. extrospect/llm/router.py +170 -0
  42. extrospect/llm/sanitize.py +322 -0
  43. extrospect/llm/tools.py +646 -0
  44. extrospect/llm/triage.py +326 -0
  45. extrospect/llm/verifier.py +228 -0
  46. extrospect/models.py +497 -0
  47. extrospect/pipeline.py +375 -0
  48. extrospect/report.py +736 -0
  49. extrospect/static/__init__.py +1 -0
  50. extrospect/static/analyzer.py +221 -0
  51. extrospect/static/csp.py +114 -0
  52. extrospect/static/deobfuscate.py +177 -0
  53. extrospect/static/features.py +239 -0
  54. extrospect/static/jsparse.py +471 -0
  55. extrospect/static/libstrip.py +132 -0
  56. extrospect/static/manifest.py +457 -0
  57. extrospect/static/nodetools.py +61 -0
  58. extrospect/static/permissions.py +402 -0
  59. extrospect/static/sinks.py +1081 -0
  60. extrospect/static/slicer.py +303 -0
  61. extrospect/static/surface.py +398 -0
  62. extrospect/static/vulns.py +416 -0
  63. extrospect/trace.py +190 -0
  64. extrospect/util.py +224 -0
  65. extrospect/workspace.py +342 -0
  66. extrospect-0.1.0.dist-info/METADATA +244 -0
  67. extrospect-0.1.0.dist-info/RECORD +73 -0
  68. extrospect-0.1.0.dist-info/WHEEL +4 -0
  69. extrospect-0.1.0.dist-info/entry_points.txt +3 -0
  70. extrospect-0.1.0.dist-info/licenses/LICENSE +202 -0
  71. extrospect-0.1.0.dist-info/licenses/NOTICE +4 -0
  72. extrospect_mcp/__init__.py +1 -0
  73. extrospect_mcp/server.py +550 -0
extrospect/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Extrospect: local browser-extension risk analysis with code evidence."""
2
+
3
+ __version__ = "0.1.0"
extrospect/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from extrospect.cli import main
2
+
3
+ main()
@@ -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."""