返回 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: Domain to match (substring match, e.g. "x.com")
102 cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"])
103
104 Returns:
105 Dict mapping cookie name to value for found cookies, or None on failure.
106 """
107 if sys.platform != "darwin":
108 return None
109
110 cookie_paths = [
111 Path.home()
112 / "Library"
113 / "Containers"
114 / "com.apple.Safari"
115 / "Data"
116 / "Library"
117 / "Cookies"
118 / "Cookies.binarycookies",
119 Path.home() / "Library" / "Cookies" / "Cookies.binarycookies",
120 ]
121 cookie_path = next((path for path in cookie_paths if path.exists()), cookie_paths[0])
122
123 try:
124 raw = cookie_path.read_bytes()
125 except FileNotFoundError:
126 return None
127 except PermissionError:
128 print(
129 "[safari] Permission denied reading Cookies.binarycookies. "
130 "Enable Full Disk Access for Terminal in System Settings > "
131 "Privacy & Security > Full Disk Access.",
132 file=sys.stderr,
133 )
134 return None
135 except OSError:
136 return None
137
138 return _parse_binary_cookies(raw, domain, cookie_names)
139
140
141 def _parse_binary_cookies(
142 raw: bytes, domain: str, cookie_names: list[str]
143 ) -> dict[str, str] | None:
144 """Parse raw binary cookie data. Separated for testability."""
145 if len(raw) < 8:
146 return None
147
148 # Validate magic
149 if raw[:4] != _MAGIC:
150 return None
151
152 try:
153 (num_pages,) = struct.unpack(">I", raw[4:8])
154 except struct.error:
155 return None
156
157 if num_pages > 100000:
158 return None
159
160 # Read page sizes (big-endian uint32 array)
161 page_sizes_end = 8 + num_pages * 4
162 if page_sizes_end > len(raw):
163 return None
164
165 page_sizes = []
166 for i in range(num_pages):
167 off = 8 + i * 4
168 try:
169 (ps,) = struct.unpack(">I", raw[off : off + 4])
170 page_sizes.append(ps)
171 except struct.error:
172 return None
173
174 # Parse each page
175 names_set = set(cookie_names)
176 result: dict[str, str] = {}
177 offset = page_sizes_end
178
179 for ps in page_sizes:
180 if offset + ps > len(raw):
181 break
182 page_data = raw[offset : offset + ps]
183 cookies = _parse_page(page_data)
184 for c in cookies:
185 # Substring match on domain (handles leading dots like ".x.com")
186 if domain in c["url"] and c["name"] in names_set:
187 result[c["name"]] = c["value"]
188 offset += ps
189
190 if not result:
191 return None
192
193 return result
194
194 lines PYTHON