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