返回 last30days-skill
safari_cookies.py
根目录 / skills / last30days / scripts / lib / safari_cookies.py
1 """
2 Safari binary cookie extractor for macOS.
3
4 Parses ~/Library/Cookies/Cookies.binarycookies (unencrypted binary format)
5 using only stdlib. Zero pip dependencies.
6
7 Reference: github.com/mdegrazia/Safari-Binary-Cookie-Parser
8 """
9
10 from __future__ import annotations
11
12 import io
13 import struct
14 import sys
15 from pathlib import Path
16
17 # Mac epoch: 2001-01-01 00:00:00 UTC (not used for filtering, but documented)
18 _MAC_EPOCH_OFFSET = 978307200 # seconds between Unix epoch and Mac epoch
19
20 _MAGIC = b"cook"
21
22
23 def _read_null_terminated(data: bytes, offset: int) -> str:
24 """Read a null-terminated string from data starting at offset."""
25 end = data.find(b"\x00", offset)
26 if end == -1:
27 end = len(data)
28 return data[offset:end].decode("utf-8", errors="replace")
29
30
31 def _parse_cookie_record(data: bytes) -> dict | None:
32 """Parse a single cookie record. Returns dict with url, name, value, path or None."""
33 if len(data) < 44:
34 return None
35 try:
36 (size,) = struct.unpack("<I", data[0:4])
37 # flags at offset 4 (4 bytes, little-endian) — not needed for extraction
38 (url_offset,) = struct.unpack("<I", data[16:20])
39 (name_offset,) = struct.unpack("<I", data[20:24])
40 (path_offset,) = struct.unpack("<I", data[24:28])
41 (value_offset,) = struct.unpack("<I", data[28:32])
42 # expiry at offset 40 (8-byte double, little-endian) — not needed for filtering
43 # creation at offset 48 (8-byte double, little-endian) — not needed
44
45 url = _read_null_terminated(data, url_offset)
46 name = _read_null_terminated(data, name_offset)
47 path = _read_null_terminated(data, path_offset)
48 value = _read_null_terminated(data, value_offset)
49
50 return {"url": url, "name": name, "value": value, "path": path}
51 except (struct.error, IndexError, UnicodeDecodeError):
52 return None
53
54
55 def _parse_page(page_data: bytes) -> list[dict]:
56 """Parse a single page of cookies. Returns list of cookie dicts."""
57 cookies = []
58 if len(page_data) < 8:
59 return cookies
60
61 # Page header: 4 bytes (always 00 00 01 00), then 4-byte LE cookie count
62 try:
63 (num_cookies,) = struct.unpack("<I", page_data[4:8])
64 except struct.error:
65 return cookies
66
67 # Sanity check
68 if num_cookies > 10000:
69 return cookies
70
71 # Cookie offsets: array of 4-byte LE uint32 starting at offset 8
72 offsets_end = 8 + num_cookies * 4
73 if offsets_end > len(page_data):
74 return cookies
75
76 for i in range(num_cookies):
77 off_start = 8 + i * 4
78 try:
79 (cookie_offset,) = struct.unpack("<I", page_data[off_start : off_start + 4])
80 except struct.error:
81 continue
82
83 if cookie_offset >= len(page_data):
84 continue
85
86 cookie_data = page_data[cookie_offset:]
87 record = _parse_cookie_record(cookie_data)
88 if record:
89 cookies.append(record)
90
91 return cookies
92
93
94 def extract_safari_cookies_macos(
95 domain: str, cookie_names: list[str]
96 ) -> dict[str, str] | None:
97 """
98 Extract cookies from Safari on macOS.
99
100 Args:
101 domain: Registrable host to match, with or without a leading dot
102 (e.g. "x.com" or ".x.com"). A stored cookie host matches when it
103 equals the domain or is a subdomain of it; unrelated hosts that
104 merely contain the text (e.g. "x.com.evil.tld") do not.
105 cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"])
106
107 Returns:
108 Dict mapping cookie name to value for found cookies, or None on failure.
109 """
110 if sys.platform != "darwin":
111 return None
112
113 cookie_paths = [
114 Path.home()
115 / "Library"
116 / "Containers"
117 / "com.apple.Safari"
118 / "Data"
119 / "Library"
120 / "Cookies"
121 / "Cookies.binarycookies",
122 Path.home() / "Library" / "Cookies" / "Cookies.binarycookies",
123 ]
124 cookie_path = next((path for path in cookie_paths if path.exists()), cookie_paths[0])
125
126 try:
127 raw = cookie_path.read_bytes()
128 except FileNotFoundError:
129 return None
130 except PermissionError:
131 print(
132 "[safari] Permission denied reading Cookies.binarycookies. "
133 "Enable Full Disk Access for Terminal in System Settings > "
134 "Privacy & Security > Full Disk Access.",
135 file=sys.stderr,
136 )
137 return None
138 except OSError:
139 return None
140
141 return _parse_binary_cookies(raw, domain, cookie_names)
142
143
144 def _host_matches(stored_host: str, domain: str) -> bool:
145 """True when stored_host equals domain or is a subdomain of it.
146
147 Safari stores domain cookies with a leading dot (".x.com") and host-only
148 cookies without one ("x.com"); both spellings are accepted on either side.
149 """
150 host = stored_host.strip().lstrip(".").lower()
151 wanted = domain.strip().lstrip(".").lower()
152 if not host or not wanted:
153 return False
154 return host == wanted or host.endswith("." + wanted)
155
156
157 def _parse_binary_cookies(
158 raw: bytes, domain: str, cookie_names: list[str]
159 ) -> dict[str, str] | None:
160 """Parse raw binary cookie data. Separated for testability."""
161 if len(raw) < 8:
162 return None
163
164 # Validate magic
165 if raw[:4] != _MAGIC:
166 return None
167
168 try:
169 (num_pages,) = struct.unpack(">I", raw[4:8])
170 except struct.error:
171 return None
172
173 if num_pages > 100000:
174 return None
175
176 # Read page sizes (big-endian uint32 array)
177 page_sizes_end = 8 + num_pages * 4
178 if page_sizes_end > len(raw):
179 return None
180
181 page_sizes = []
182 for i in range(num_pages):
183 off = 8 + i * 4
184 try:
185 (ps,) = struct.unpack(">I", raw[off : off + 4])
186 page_sizes.append(ps)
187 except struct.error:
188 return None
189
190 # Parse each page
191 names_set = set(cookie_names)
192 result: dict[str, str] = {}
193 offset = page_sizes_end
194
195 for ps in page_sizes:
196 if offset + ps > len(raw):
197 break
198 page_data = raw[offset : offset + ps]
199 cookies = _parse_page(page_data)
200 for c in cookies:
201 if _host_matches(c["url"], domain) and c["name"] in names_set:
202 result[c["name"]] = c["value"]
203 offset += ps
204
205 if not result:
206 return None
207
208 return result
209
209 lines PYTHON