返回 last30days-skill
cookie_extract.py
根目录 / skills / last30days / scripts / lib / cookie_extract.py
1 """Browser cookie extraction for last30days.
2
3 Extracts cookies from local browser databases (Firefox, Chrome, Brave, Safari)
4 to enable zero-config authentication for services like X/Twitter.
5 Note: Chrome/Brave extraction is macOS-only; Windows Chrome/Edge use
6 DPAPI-encrypted stores that are not yet supported.
7
8 Only uses Python stdlib — no external dependencies.
9 """
10
11 import configparser
12 import functools
13 import logging
14 import os
15 import platform
16 import shutil
17 import sqlite3
18 import tempfile
19 from pathlib import Path
20 from typing import Dict, List, Optional
21
22 logger = logging.getLogger(__name__)
23
24
25 def _lock_temp_cookie_copy(path: str) -> None:
26 """Restrict copied cookie DB temp files to the current user on POSIX."""
27 if os.name == "nt":
28 return
29 Path(path).chmod(0o600)
30
31
32 @functools.lru_cache(maxsize=1)
33 def _is_wsl() -> bool:
34 """Detect if running under Windows Subsystem for Linux.
35
36 Cached after the first call since /proc/version doesn't change at runtime.
37 """
38 try:
39 return "microsoft" in Path("/proc/version").read_text().lower()
40 except OSError:
41 return False
42
43
44 def _get_wsl_firefox_profiles_dir() -> Optional[Path]:
45 """Find Firefox profiles directory on the Windows host from WSL.
46
47 Scans /mnt/c/Users/*/AppData/Roaming/Mozilla/Firefox for real user
48 directories (skips Public, Default, etc.).
49 """
50 mnt_users = Path("/mnt/c/Users")
51 if not mnt_users.is_dir():
52 return None
53 skip = {"Public", "Default", "Default User", "All Users"}
54 try:
55 for user_dir in sorted(mnt_users.iterdir()):
56 if user_dir.name in skip or not user_dir.is_dir():
57 continue
58 ff_dir = user_dir / "AppData" / "Roaming" / "Mozilla" / "Firefox"
59 if ff_dir.is_dir():
60 return ff_dir
61 except OSError:
62 pass
63 return None
64
65
66 def _get_firefox_profiles_dir() -> Optional[Path]:
67 """Return the Firefox profiles directory for the current platform, or None."""
68 system = platform.system()
69 if system == "Darwin":
70 path = Path.home() / "Library" / "Application Support" / "Firefox"
71 elif system == "Linux":
72 # Default location for most distros
73 path = Path.home() / ".mozilla" / "firefox"
74 if path.is_dir():
75 return path
76 # Some distros (e.g. Fedora) honour $XDG_CONFIG_HOME
77 xdg_config = os.environ.get("XDG_CONFIG_HOME")
78 if xdg_config and os.path.isabs(xdg_config):
79 path = Path(xdg_config) / "mozilla" / "firefox"
80 else:
81 path = Path.home() / ".config" / "mozilla" / "firefox"
82 else:
83 # Windows: %APPDATA%\Mozilla\Firefox — best-effort
84 appdata = Path.home() / "AppData" / "Roaming" / "Mozilla" / "Firefox"
85 path = appdata
86 return path if path.is_dir() else None
87
88
89 def _load_profiles_ini(ini_path: Path) -> configparser.ConfigParser:
90 """Parse Firefox profiles.ini, retrying UTF-16 LE used on Windows (#1067)."""
91 config = configparser.ConfigParser()
92 try:
93 config.read(str(ini_path), encoding="utf-8")
94 except UnicodeDecodeError:
95 config.read(str(ini_path), encoding="utf-16")
96 return config
97
98
99 def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
100 """Parse profiles.ini to find the default profile directory.
101
102 Looks for a section with Default=1. Falls back to the first profile
103 directory found on disk if profiles.ini is missing or malformed.
104 """
105 ini_path = profiles_dir / "profiles.ini"
106
107 if ini_path.is_file():
108 try:
109 config = _load_profiles_ini(ini_path)
110
111 # First pass: Install* section (Firefox >= 67 format, takes priority)
112 for section in config.sections():
113 if section.startswith("Install") and config.has_option(section, "Default"):
114 raw = config.get(section, "Default")
115 candidate = profiles_dir / raw
116 if candidate.is_dir():
117 return candidate
118
119 # Second pass: Profile section with Default=1
120 for section in config.sections():
121 if section.startswith("Profile") and config.has_option(section, "Default") and config.get(section, "Default") == "1":
122 return _resolve_profile_path(profiles_dir, config, section)
123
124 # Third pass: first Profile section that exists on disk
125 for section in config.sections():
126 if section.startswith("Profile"):
127 resolved = _resolve_profile_path(profiles_dir, config, section)
128 if resolved and resolved.is_dir():
129 return resolved
130 except (configparser.Error, OSError, UnicodeDecodeError) as exc:
131 logger.debug("Failed to parse profiles.ini: %s", exc)
132
133 # Fallback: scan directory for anything that looks like a profile
134 return _fallback_find_profile(profiles_dir)
135
136
137 def _resolve_profile_path(
138 profiles_dir: Path, config: configparser.ConfigParser, section: str
139 ) -> Optional[Path]:
140 """Resolve a profile path from a ConfigParser section."""
141 if not config.has_option(section, "Path"):
142 return None
143 raw_path = config.get(section, "Path")
144 is_relative = config.has_option(section, "IsRelative") and config.get(section, "IsRelative") == "1"
145 if is_relative:
146 candidate = profiles_dir / raw_path
147 else:
148 candidate = Path(raw_path)
149 return candidate if candidate.is_dir() else None
150
151
152 def _fallback_find_profile(profiles_dir: Path) -> Optional[Path]:
153 """Find the first directory that contains cookies.sqlite."""
154 try:
155 for child in sorted(profiles_dir.iterdir()):
156 if child.is_dir() and (child / "cookies.sqlite").is_file():
157 return child
158 except OSError:
159 pass
160 return None
161
162
163 def _query_cookies_db(
164 db_path: Path, domain: str, cookie_names: List[str]
165 ) -> Optional[Dict[str, str]]:
166 """Copy the cookies database to a temp file and query it.
167
168 Firefox locks cookies.sqlite while running, so we copy first.
169 Returns {name: value} dict or None if no matching cookies found.
170 """
171 if not db_path.is_file():
172 return None
173
174 tmp_fd = None
175 tmp_path = None
176 try:
177 tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite")
178 # mkstemp creates the file 0600. copy2 would copy the source's mode
179 # (Firefox cookies.sqlite is commonly 0644, looser on WSL /mnt/c) onto
180 # the temp file, leaving live session secrets world-readable in shared
181 # /tmp until the chmod below runs. copyfile writes content only and
182 # leaves the 0600 perms intact, closing that window.
183 shutil.copyfile(str(db_path), tmp_path)
184 _lock_temp_cookie_copy(tmp_path)
185
186 conn = sqlite3.connect(tmp_path)
187 try:
188 # Build parameterized query — SQLite doesn't support array params,
189 # so we build the IN clause with individual placeholders.
190 placeholders = ",".join("?" for _ in cookie_names)
191 query = (
192 f"SELECT name, value FROM moz_cookies "
193 f"WHERE host LIKE ? AND name IN ({placeholders})"
194 )
195 # domain pattern: match .x.com, x.com, etc.
196 domain_pattern = f"%{domain}"
197 params = [domain_pattern] + list(cookie_names)
198
199 cursor = conn.execute(query, params)
200 rows = cursor.fetchall()
201 finally:
202 conn.close()
203
204 if not rows:
205 return None
206 return {name: value for name, value in rows}
207
208 except (sqlite3.Error, OSError) as exc:
209 logger.debug("Failed to query cookies database %s: %s", db_path, exc)
210 return None
211 finally:
212 if tmp_path:
213 try:
214 Path(tmp_path).unlink(missing_ok=True)
215 except OSError:
216 pass
217 if tmp_fd is not None:
218 try:
219 import os
220 os.close(tmp_fd)
221 except OSError:
222 pass
223
224
225 def _try_firefox_dir(profiles_dir: Path, domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
226 """Try to extract cookies from a Firefox profiles directory.
227
228 Tries the default profile first, then falls back to scanning all
229 profiles for matching cookies. This handles multi-profile setups
230 where the user is logged into x.com on a non-default profile.
231 """
232 default_profile = _find_default_profile(profiles_dir)
233 profiles_tried = 0
234 if default_profile is not None:
235 result = _query_cookies_db(default_profile / "cookies.sqlite", domain, cookie_names)
236 if result is not None:
237 return result
238 profiles_tried = 1
239 # Fallback: scan every profile directory for matching cookies
240 try:
241 for child in sorted(profiles_dir.iterdir()):
242 if not child.is_dir():
243 continue
244 if default_profile is not None and child == default_profile:
245 continue
246 db = child / "cookies.sqlite"
247 if db.is_file():
248 result = _query_cookies_db(db, domain, cookie_names)
249 if result is not None:
250 return result
251 profiles_tried += 1
252 except OSError:
253 pass
254 logger.debug("No matching cookies found in %d Firefox profile(s)", profiles_tried)
255 return None
256
257
258 def extract_firefox_cookies(
259 domain: str, cookie_names: List[str]
260 ) -> Optional[Dict[str, str]]:
261 """Extract cookies from Firefox for the given domain and cookie names.
262
263 Finds the default Firefox profile, copies cookies.sqlite to a temp file
264 (to avoid lock conflicts), and queries for the requested cookies.
265
266 On WSL2, falls back to Windows Firefox if native Linux Firefox has no
267 matching cookies. Windows Firefox cookies are unencrypted, so this works
268 without DPAPI or any Windows-side helpers.
269
270 Args:
271 domain: The cookie domain to match (e.g. ".x.com"). Matched with LIKE %domain.
272 cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"]).
273
274 Returns:
275 Dict of {cookie_name: cookie_value} or None if extraction fails.
276 """
277 profiles_dir = _get_firefox_profiles_dir()
278 if profiles_dir is not None:
279 result = _try_firefox_dir(profiles_dir, domain, cookie_names)
280 if result is not None:
281 return result
282
283 if platform.system() == "Linux" and _is_wsl():
284 wsl_dir = _get_wsl_firefox_profiles_dir()
285 if wsl_dir is not None:
286 logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
287 return _try_firefox_dir(wsl_dir, domain, cookie_names)
288
289 if profiles_dir is None:
290 logger.debug("Firefox profiles directory not found")
291 return None
292
293
294 def extract_chrome_cookies(
295 domain: str, cookie_names: List[str]
296 ) -> Optional[Dict[str, str]]:
297 """Extract cookies from Chrome for the given domain and cookie names.
298
299 macOS only — uses Keychain + system openssl for AES-128-CBC decryption.
300 Linux/Windows not supported (Chrome uses platform-specific encryption).
301
302 Returns:
303 Dict of {cookie_name: cookie_value} or None if extraction fails.
304 """
305 if platform.system() != "Darwin":
306 logger.debug("Chrome cookie extraction only supported on macOS")
307 return None
308 try:
309 from .chrome_cookies import extract_chrome_cookies_macos
310 return extract_chrome_cookies_macos(domain, cookie_names)
311 except Exception as exc:
312 logger.debug("Chrome cookie extraction failed: %s", exc)
313 return None
314
315
316 def extract_brave_cookies(
317 domain: str, cookie_names: List[str]
318 ) -> Optional[Dict[str, str]]:
319 """Extract cookies from Brave for the given domain and cookie names.
320
321 macOS only — Brave uses the same v10 AES-128-CBC encryption as Chrome,
322 with a different DB path and Keychain service name ("Brave Safe Storage").
323 Tries the Default profile first, then scans numbered Profile directories.
324
325 Returns:
326 Dict of {cookie_name: cookie_value} or None if extraction fails.
327 """
328 if platform.system() != "Darwin":
329 logger.debug("Brave cookie extraction only supported on macOS")
330 return None
331 try:
332 from .chrome_cookies import extract_brave_cookies_macos
333 return extract_brave_cookies_macos(domain, cookie_names)
334 except Exception as exc:
335 logger.debug("Brave cookie extraction failed: %s", exc)
336 return None
337
338
339 def _extract_chromium_family_cookies(
340 browser: str, domain: str, cookie_names: List[str]
341 ) -> Optional[Dict[str, str]]:
342 """Extract cookies from a non-Chrome/Brave Chromium browser on macOS.
343
344 macOS only — Edge, Vivaldi, Opera, Arc, and Chromium all reuse Chrome's
345 v10 AES-128-CBC encryption, with their own profile path and Keychain
346 service name (see chrome_cookies.CHROMIUM_BROWSER_PROFILES).
347 """
348 if platform.system() != "Darwin":
349 logger.debug("%s cookie extraction only supported on macOS", browser)
350 return None
351 try:
352 from .chrome_cookies import extract_chromium_browser_cookies_macos
353 return extract_chromium_browser_cookies_macos(browser, domain, cookie_names)
354 except Exception as exc:
355 logger.debug("%s cookie extraction failed: %s", browser, exc)
356 return None
357
358
359 def extract_edge_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
360 """Extract cookies from Microsoft Edge for the given domain (macOS only)."""
361 return _extract_chromium_family_cookies("edge", domain, cookie_names)
362
363
364 def extract_vivaldi_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
365 """Extract cookies from Vivaldi for the given domain (macOS only)."""
366 return _extract_chromium_family_cookies("vivaldi", domain, cookie_names)
367
368
369 def extract_opera_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
370 """Extract cookies from Opera for the given domain (macOS only)."""
371 return _extract_chromium_family_cookies("opera", domain, cookie_names)
372
373
374 def extract_arc_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
375 """Extract cookies from Arc for the given domain (macOS only)."""
376 return _extract_chromium_family_cookies("arc", domain, cookie_names)
377
378
379 def extract_chromium_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
380 """Extract cookies from open-source Chromium for the given domain (macOS only)."""
381 return _extract_chromium_family_cookies("chromium", domain, cookie_names)
382
383
384 def extract_safari_cookies(
385 domain: str, cookie_names: List[str]
386 ) -> Optional[Dict[str, str]]:
387 """Extract cookies from Safari for the given domain and cookie names.
388
389 macOS only — parses the unencrypted binary cookie file.
390
391 Returns:
392 Dict of {cookie_name: cookie_value} or None if extraction fails.
393 """
394 if platform.system() != "Darwin":
395 logger.debug("Safari cookie extraction only supported on macOS")
396 return None
397 try:
398 from .safari_cookies import extract_safari_cookies_macos
399 return extract_safari_cookies_macos(domain, cookie_names)
400 except Exception as exc:
401 logger.debug("Safari cookie extraction failed: %s", exc)
402 return None
403
404
405 def extract_cookies(
406 browser: str, domain: str, cookie_names: list[str]
407 ) -> Optional[dict[str, str]]:
408 """Extract cookies from the specified browser.
409
410 Args:
411 browser: One of 'firefox', 'chrome', 'brave', 'edge', 'vivaldi',
412 'opera', 'arc', 'chromium', 'safari', or 'auto'.
413 'auto' tries browsers in platform-appropriate order:
414 - macOS: Chrome -> Brave -> Edge -> Vivaldi -> Opera -> Arc -> Chromium -> Firefox -> Safari
415 - Linux: Firefox only
416 domain: The cookie domain to match (e.g. ".x.com").
417 cookie_names: List of cookie names to extract.
418
419 Returns:
420 Dict of {cookie_name: cookie_value} or None if extraction fails.
421 """
422 result = extract_cookies_with_source(browser, domain, cookie_names)
423 if result is None:
424 return None
425 cookies, _browser_name = result
426 return cookies
427
428
429 def _extract_firefox_with_source(
430 domain: str, cookie_names: List[str]
431 ) -> Optional[tuple[Dict[str, str], str]]:
432 """Extract Firefox cookies and report whether they came from native or WSL.
433
434 Returns (cookies, "firefox") for native Linux/macOS Firefox, or
435 (cookies, "firefox-wsl") for Windows Firefox accessed via WSL2.
436 """
437 profiles_dir = _get_firefox_profiles_dir()
438 if profiles_dir is not None:
439 result = _try_firefox_dir(profiles_dir, domain, cookie_names)
440 if result is not None:
441 return (result, "firefox")
442
443 if platform.system() == "Linux" and _is_wsl():
444 wsl_dir = _get_wsl_firefox_profiles_dir()
445 if wsl_dir is not None:
446 logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
447 result = _try_firefox_dir(wsl_dir, domain, cookie_names)
448 if result is not None:
449 return (result, "firefox-wsl")
450
451 return None
452
453
454 def extract_cookies_with_source(
455 browser: str, domain: str, cookie_names: list[str]
456 ) -> Optional[tuple[dict[str, str], str]]:
457 """Extract cookies and report which browser they came from.
458
459 Same as extract_cookies() but returns a (cookies, browser_name) tuple
460 so callers can track the source.
461
462 Args:
463 browser: One of 'firefox', 'chrome', 'brave', 'edge', 'vivaldi',
464 'opera', 'arc', 'chromium', 'safari', or 'auto'.
465 domain: The cookie domain to match (e.g. ".x.com").
466 cookie_names: List of cookie names to extract.
467
468 Returns:
469 Tuple of ({cookie_name: cookie_value}, browser_name) or None.
470 browser_name is "firefox-wsl" when cookies came from Windows Firefox via WSL2.
471 """
472 extractors = {
473 "firefox": extract_firefox_cookies,
474 "chrome": extract_chrome_cookies,
475 "brave": extract_brave_cookies,
476 "edge": extract_edge_cookies,
477 "vivaldi": extract_vivaldi_cookies,
478 "opera": extract_opera_cookies,
479 "arc": extract_arc_cookies,
480 "chromium": extract_chromium_cookies,
481 "safari": extract_safari_cookies,
482 }
483
484 if browser != "auto":
485 if browser == "firefox":
486 return _extract_firefox_with_source(domain, cookie_names)
487 extractor = extractors.get(browser)
488 if extractor is None:
489 logger.warning("Unknown browser: %s", browser)
490 return None
491 result = extractor(domain, cookie_names)
492 return (result, browser) if result is not None else None
493
494 # Auto mode: try browsers in platform-appropriate order.
495 # Note: the skill's own entry point (env.extract_browser_credentials) builds
496 # its own list that tries the SILENT browsers (Firefox, Safari) first to
497 # avoid macOS Keychain prompts. This standalone "auto" is Chromium-first; the
498 # two orderings are intentional for their respective callers.
499 system = platform.system()
500 if system == "Darwin":
501 order = ["chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium", "firefox", "safari"]
502 elif system == "Linux":
503 order = ["firefox"]
504 else:
505 order = ["firefox"]
506
507 for name in order:
508 if name == "firefox":
509 result = _extract_firefox_with_source(domain, cookie_names)
510 if result is not None:
511 return result
512 else:
513 result = extractors[name](domain, cookie_names)
514 if result is not None:
515 return (result, name)
516
517 return None
518
518 lines PYTHON