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