cookietuner 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cookietuner-0.1.1/PKG-INFO +125 -0
- cookietuner-0.1.1/README.md +97 -0
- cookietuner-0.1.1/pyproject.toml +50 -0
- cookietuner-0.1.1/src/cookietuner/__init__.py +6 -0
- cookietuner-0.1.1/src/cookietuner/chrome.py +223 -0
- cookietuner-0.1.1/src/cookietuner/cli.py +163 -0
- cookietuner-0.1.1/src/cookietuner/models.py +44 -0
- cookietuner-0.1.1/src/cookietuner/safari.py +197 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cookietuner
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Extract and display cookies from macOS browsers (Chrome, Safari)
|
|
5
|
+
Keywords: cookies,browser,chrome,safari,macos,cli
|
|
6
|
+
Author: David Poblador i Garcia
|
|
7
|
+
Author-email: David Poblador i Garcia <david@poblador.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Environment :: MacOS X
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Dist: cryptography>=46.0.3
|
|
20
|
+
Requires-Dist: pydantic>=2.12.5
|
|
21
|
+
Requires-Dist: typer>=0.21.1
|
|
22
|
+
Requires-Python: >=3.14
|
|
23
|
+
Project-URL: Homepage, https://github.com/alltuner/cookietuner
|
|
24
|
+
Project-URL: Documentation, https://cookietuner.alltuner.com
|
|
25
|
+
Project-URL: Repository, https://github.com/alltuner/cookietuner
|
|
26
|
+
Project-URL: Issues, https://github.com/alltuner/cookietuner/issues
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# cookietuner
|
|
30
|
+
|
|
31
|
+
A command-line tool to extract and display cookies from macOS browsers.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
The easiest way to run cookietuner is with [uv](https://docs.astral.sh/uv/):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uvx cookietuner
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install it permanently:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install cookietuner
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### List cookies
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Chrome cookies (browser is required)
|
|
53
|
+
cookietuner cookies -b chrome
|
|
54
|
+
|
|
55
|
+
# Safari cookies
|
|
56
|
+
cookietuner cookies -b safari
|
|
57
|
+
|
|
58
|
+
# Filter by domain
|
|
59
|
+
cookietuner cookies -b chrome -d google.com
|
|
60
|
+
|
|
61
|
+
# Use a specific Chrome profile
|
|
62
|
+
cookietuner cookies -b chrome -p "Profile 1"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Output formats
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Full table with all details (default)
|
|
69
|
+
cookietuner cookies -b chrome -o table
|
|
70
|
+
|
|
71
|
+
# Short table with just domain, name, value
|
|
72
|
+
cookietuner cookies -b chrome -o short
|
|
73
|
+
|
|
74
|
+
# Space-separated line format (for scripting)
|
|
75
|
+
cookietuner cookies -b chrome -o line
|
|
76
|
+
|
|
77
|
+
# JSON output
|
|
78
|
+
cookietuner cookies -b chrome -o json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### List browser profiles
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# List all profiles
|
|
85
|
+
cookietuner profiles
|
|
86
|
+
|
|
87
|
+
# Filter by browser
|
|
88
|
+
cookietuner profiles -b chrome
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Features
|
|
92
|
+
|
|
93
|
+
- **Chrome support**: Decrypts cookies using macOS Keychain, supports Chrome 130+ format
|
|
94
|
+
- **Safari support**: Parses the binary cookies format with SameSite detection
|
|
95
|
+
- **Multiple output formats**: table, short, and JSON
|
|
96
|
+
- **Domain filtering**: Filter cookies by partial domain match
|
|
97
|
+
- **Profile selection**: Choose which browser profile to read from
|
|
98
|
+
- **Cookie metadata**: Shows expiration, Secure, HttpOnly, and SameSite flags
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- macOS (the tool only works on macOS)
|
|
103
|
+
- Python 3.14+
|
|
104
|
+
- Chrome and/or Safari browser
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Clone the repository
|
|
110
|
+
git clone https://github.com/alltuner/cookietuner.git
|
|
111
|
+
cd cookietuner
|
|
112
|
+
|
|
113
|
+
# Install dependencies
|
|
114
|
+
uv sync
|
|
115
|
+
|
|
116
|
+
# Run tests
|
|
117
|
+
uv run pytest
|
|
118
|
+
|
|
119
|
+
# Run the CLI
|
|
120
|
+
uv run cookietuner
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# cookietuner
|
|
2
|
+
|
|
3
|
+
A command-line tool to extract and display cookies from macOS browsers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
The easiest way to run cookietuner is with [uv](https://docs.astral.sh/uv/):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uvx cookietuner
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install it permanently:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install cookietuner
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### List cookies
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Chrome cookies (browser is required)
|
|
25
|
+
cookietuner cookies -b chrome
|
|
26
|
+
|
|
27
|
+
# Safari cookies
|
|
28
|
+
cookietuner cookies -b safari
|
|
29
|
+
|
|
30
|
+
# Filter by domain
|
|
31
|
+
cookietuner cookies -b chrome -d google.com
|
|
32
|
+
|
|
33
|
+
# Use a specific Chrome profile
|
|
34
|
+
cookietuner cookies -b chrome -p "Profile 1"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Output formats
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Full table with all details (default)
|
|
41
|
+
cookietuner cookies -b chrome -o table
|
|
42
|
+
|
|
43
|
+
# Short table with just domain, name, value
|
|
44
|
+
cookietuner cookies -b chrome -o short
|
|
45
|
+
|
|
46
|
+
# Space-separated line format (for scripting)
|
|
47
|
+
cookietuner cookies -b chrome -o line
|
|
48
|
+
|
|
49
|
+
# JSON output
|
|
50
|
+
cookietuner cookies -b chrome -o json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### List browser profiles
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# List all profiles
|
|
57
|
+
cookietuner profiles
|
|
58
|
+
|
|
59
|
+
# Filter by browser
|
|
60
|
+
cookietuner profiles -b chrome
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Chrome support**: Decrypts cookies using macOS Keychain, supports Chrome 130+ format
|
|
66
|
+
- **Safari support**: Parses the binary cookies format with SameSite detection
|
|
67
|
+
- **Multiple output formats**: table, short, and JSON
|
|
68
|
+
- **Domain filtering**: Filter cookies by partial domain match
|
|
69
|
+
- **Profile selection**: Choose which browser profile to read from
|
|
70
|
+
- **Cookie metadata**: Shows expiration, Secure, HttpOnly, and SameSite flags
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
- macOS (the tool only works on macOS)
|
|
75
|
+
- Python 3.14+
|
|
76
|
+
- Chrome and/or Safari browser
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Clone the repository
|
|
82
|
+
git clone https://github.com/alltuner/cookietuner.git
|
|
83
|
+
cd cookietuner
|
|
84
|
+
|
|
85
|
+
# Install dependencies
|
|
86
|
+
uv sync
|
|
87
|
+
|
|
88
|
+
# Run tests
|
|
89
|
+
uv run pytest
|
|
90
|
+
|
|
91
|
+
# Run the CLI
|
|
92
|
+
uv run cookietuner
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cookietuner"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Extract and display cookies from macOS browsers (Chrome, Safari)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "David Poblador i Garcia", email = "david@poblador.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
keywords = ["cookies", "browser", "chrome", "safari", "macos", "cli"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Environment :: MacOS X",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"cryptography>=46.0.3",
|
|
26
|
+
"pydantic>=2.12.5",
|
|
27
|
+
"typer>=0.21.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/alltuner/cookietuner"
|
|
32
|
+
Documentation = "https://cookietuner.alltuner.com"
|
|
33
|
+
Repository = "https://github.com/alltuner/cookietuner"
|
|
34
|
+
Issues = "https://github.com/alltuner/cookietuner/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
cookietuner = "cookietuner:main"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"mkdocs>=1.6.1",
|
|
46
|
+
"mkdocs-material>=9.7.1",
|
|
47
|
+
"prek>=0.3.0",
|
|
48
|
+
"pytest>=9.0.2",
|
|
49
|
+
"ruff>=0.14.14",
|
|
50
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# ABOUTME: Chrome cookie extraction for macOS
|
|
2
|
+
# ABOUTME: Reads and decrypts cookies from Chrome's SQLite database
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import sqlite3
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from hashlib import pbkdf2_hmac
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.backends import default_backend
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
14
|
+
|
|
15
|
+
from .models import BrowserProfile, Cookie
|
|
16
|
+
|
|
17
|
+
# Chrome uses microseconds since Jan 1, 1601 (Windows epoch)
|
|
18
|
+
CHROME_EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
|
|
19
|
+
|
|
20
|
+
SAME_SITE_MAP: dict[int, str | None] = {
|
|
21
|
+
-1: None,
|
|
22
|
+
0: "none",
|
|
23
|
+
1: "lax",
|
|
24
|
+
2: "strict",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
CHROME_BASE_PATH = Path.home() / "Library/Application Support/Google/Chrome"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_profiles() -> list[BrowserProfile]:
|
|
31
|
+
"""Lists all available Chrome profiles."""
|
|
32
|
+
profiles = []
|
|
33
|
+
|
|
34
|
+
if not CHROME_BASE_PATH.exists():
|
|
35
|
+
return profiles
|
|
36
|
+
|
|
37
|
+
for item in CHROME_BASE_PATH.iterdir():
|
|
38
|
+
if item.is_dir():
|
|
39
|
+
cookies_file = item / "Cookies"
|
|
40
|
+
if cookies_file.exists():
|
|
41
|
+
# "Default" is the main profile, others are "Profile 1", "Profile 2", etc.
|
|
42
|
+
profile_name = item.name
|
|
43
|
+
if profile_name == "Default":
|
|
44
|
+
profile_name = "Default"
|
|
45
|
+
profiles.append(
|
|
46
|
+
BrowserProfile(
|
|
47
|
+
browser="chrome",
|
|
48
|
+
profile_name=profile_name,
|
|
49
|
+
path=str(item),
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return profiles
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_cookie_path(profile: str = "Default") -> Path:
|
|
57
|
+
"""Returns the path to Chrome's cookie database for a given profile."""
|
|
58
|
+
return CHROME_BASE_PATH / profile / "Cookies"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_encryption_key() -> bytes:
|
|
62
|
+
"""Retrieves Chrome's encryption key from macOS Keychain."""
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
[
|
|
65
|
+
"security",
|
|
66
|
+
"find-generic-password",
|
|
67
|
+
"-s",
|
|
68
|
+
"Chrome Safe Storage",
|
|
69
|
+
"-w",
|
|
70
|
+
],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=True,
|
|
74
|
+
)
|
|
75
|
+
password = result.stdout.strip()
|
|
76
|
+
|
|
77
|
+
# Derive the key using PBKDF2
|
|
78
|
+
# Chrome uses "saltysalt" as salt and 1003 iterations on macOS
|
|
79
|
+
key = pbkdf2_hmac(
|
|
80
|
+
"sha1",
|
|
81
|
+
password.encode("utf-8"),
|
|
82
|
+
b"saltysalt",
|
|
83
|
+
1003,
|
|
84
|
+
dklen=16,
|
|
85
|
+
)
|
|
86
|
+
return key
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decrypt_value(encrypted_value: bytes, key: bytes, strip_hash: bool) -> str:
|
|
90
|
+
"""Decrypts a Chrome cookie value."""
|
|
91
|
+
if not encrypted_value:
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
# Chrome prepends 'v10' to encrypted values on macOS
|
|
95
|
+
if encrypted_value[:3] == b"v10":
|
|
96
|
+
encrypted_value = encrypted_value[3:]
|
|
97
|
+
|
|
98
|
+
# Chrome uses AES-128-CBC with a 16-byte IV of spaces
|
|
99
|
+
iv = b" " * 16
|
|
100
|
+
cipher = Cipher(
|
|
101
|
+
algorithms.AES(key),
|
|
102
|
+
modes.CBC(iv),
|
|
103
|
+
backend=default_backend(),
|
|
104
|
+
)
|
|
105
|
+
decryptor = cipher.decryptor()
|
|
106
|
+
decrypted = decryptor.update(encrypted_value) + decryptor.finalize()
|
|
107
|
+
|
|
108
|
+
# Remove PKCS7 padding
|
|
109
|
+
padding_len = decrypted[-1]
|
|
110
|
+
decrypted = decrypted[:-padding_len]
|
|
111
|
+
|
|
112
|
+
# Chrome 130+ (DB version >= 24) prepends SHA256 hash of domain (32 bytes)
|
|
113
|
+
if strip_hash:
|
|
114
|
+
decrypted = decrypted[32:]
|
|
115
|
+
|
|
116
|
+
return decrypted.decode("utf-8")
|
|
117
|
+
|
|
118
|
+
# Unencrypted value (older Chrome versions)
|
|
119
|
+
return encrypted_value.decode("utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_db_version(cursor: sqlite3.Cursor) -> int:
|
|
123
|
+
"""Gets the cookie database version from the meta table."""
|
|
124
|
+
cursor.execute("SELECT value FROM meta WHERE key='version'")
|
|
125
|
+
row = cursor.fetchone()
|
|
126
|
+
return int(row[0]) if row else 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _chrome_time_to_datetime(chrome_time: int) -> datetime | None:
|
|
130
|
+
"""Converts Chrome's timestamp (microseconds since 1601) to datetime."""
|
|
131
|
+
if chrome_time == 0:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
seconds = chrome_time / 1_000_000
|
|
135
|
+
return CHROME_EPOCH + timedelta(seconds=seconds)
|
|
136
|
+
except (ValueError, OverflowError):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_cookies(
|
|
141
|
+
domain: str | None = None,
|
|
142
|
+
profile: str = "Default",
|
|
143
|
+
) -> list[Cookie]:
|
|
144
|
+
"""
|
|
145
|
+
Reads cookies from Chrome's cookie database.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
domain: If specified, only return cookies matching this domain.
|
|
149
|
+
Matches if the domain contains this string.
|
|
150
|
+
profile: Chrome profile to read from (default: "Default").
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of Cookie objects.
|
|
154
|
+
"""
|
|
155
|
+
cookie_path = _get_cookie_path(profile)
|
|
156
|
+
|
|
157
|
+
if not cookie_path.exists():
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
key = _get_encryption_key()
|
|
161
|
+
|
|
162
|
+
# Copy the database to avoid locking issues while Chrome is running
|
|
163
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
164
|
+
tmp_path = Path(tmp.name)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
shutil.copy2(cookie_path, tmp_path)
|
|
168
|
+
|
|
169
|
+
conn = sqlite3.connect(tmp_path)
|
|
170
|
+
cursor = conn.cursor()
|
|
171
|
+
|
|
172
|
+
# Check if we need to strip the hash prefix (Chrome 130+)
|
|
173
|
+
db_version = _get_db_version(cursor)
|
|
174
|
+
strip_hash = db_version >= 24
|
|
175
|
+
|
|
176
|
+
query = """
|
|
177
|
+
SELECT host_key, name, encrypted_value, path,
|
|
178
|
+
expires_utc, is_secure, is_httponly, samesite
|
|
179
|
+
FROM cookies
|
|
180
|
+
"""
|
|
181
|
+
params: tuple[str, ...] = ()
|
|
182
|
+
|
|
183
|
+
if domain:
|
|
184
|
+
query += " WHERE host_key LIKE ?"
|
|
185
|
+
params = (f"%{domain}%",)
|
|
186
|
+
|
|
187
|
+
cursor.execute(query, params)
|
|
188
|
+
rows = cursor.fetchall()
|
|
189
|
+
conn.close()
|
|
190
|
+
|
|
191
|
+
cookies = []
|
|
192
|
+
for (
|
|
193
|
+
host_key,
|
|
194
|
+
name,
|
|
195
|
+
encrypted_value,
|
|
196
|
+
path,
|
|
197
|
+
expires_utc,
|
|
198
|
+
is_secure,
|
|
199
|
+
is_httponly,
|
|
200
|
+
samesite,
|
|
201
|
+
) in rows:
|
|
202
|
+
try:
|
|
203
|
+
value = _decrypt_value(encrypted_value, key, strip_hash)
|
|
204
|
+
cookies.append(
|
|
205
|
+
Cookie(
|
|
206
|
+
domain=host_key,
|
|
207
|
+
name=name,
|
|
208
|
+
value=value,
|
|
209
|
+
path=path,
|
|
210
|
+
expires=_chrome_time_to_datetime(expires_utc),
|
|
211
|
+
is_secure=bool(is_secure),
|
|
212
|
+
is_httponly=bool(is_httponly),
|
|
213
|
+
same_site=SAME_SITE_MAP.get(samesite, "unspecified"),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
# Skip cookies that fail to decrypt
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
return cookies
|
|
221
|
+
|
|
222
|
+
finally:
|
|
223
|
+
tmp_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ABOUTME: Command-line interface using Typer
|
|
2
|
+
# ABOUTME: Provides commands to list and extract browser cookies
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from . import chrome, safari
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Extract cookies from your browsers", no_args_is_help=True)
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_macos() -> None:
|
|
19
|
+
"""Ensure we're running on macOS."""
|
|
20
|
+
if sys.platform != "darwin":
|
|
21
|
+
console.print("[red]Error: cookietuner only supports macOS[/red]")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def main_callback() -> None:
|
|
27
|
+
"""Check platform before running any command."""
|
|
28
|
+
_check_macos()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Browser(str, Enum):
|
|
32
|
+
chrome = "chrome"
|
|
33
|
+
safari = "safari"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OutputFormat(str, Enum):
|
|
37
|
+
table = "table"
|
|
38
|
+
short = "short"
|
|
39
|
+
line = "line"
|
|
40
|
+
json = "json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def cookies(
|
|
45
|
+
browser: Browser = typer.Option(
|
|
46
|
+
..., "--browser", "-b", help="Browser to extract from"
|
|
47
|
+
),
|
|
48
|
+
domain: str | None = typer.Option(
|
|
49
|
+
None, "--domain", "-d", help="Filter by domain (partial match)"
|
|
50
|
+
),
|
|
51
|
+
profile: str = typer.Option(
|
|
52
|
+
"Default", "--profile", "-p", help="Browser profile name"
|
|
53
|
+
),
|
|
54
|
+
output: OutputFormat = typer.Option(
|
|
55
|
+
OutputFormat.table, "--output", "-o", help="Output format"
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""List cookies from a browser."""
|
|
59
|
+
if browser == Browser.chrome:
|
|
60
|
+
cookie_list = chrome.get_cookies(domain=domain, profile=profile)
|
|
61
|
+
elif browser == Browser.safari:
|
|
62
|
+
cookie_list = safari.get_cookies(domain=domain)
|
|
63
|
+
|
|
64
|
+
if not cookie_list:
|
|
65
|
+
if output == OutputFormat.json:
|
|
66
|
+
print("[]")
|
|
67
|
+
elif output == OutputFormat.line:
|
|
68
|
+
pass # No output for line format when empty
|
|
69
|
+
else:
|
|
70
|
+
console.print("[yellow]No cookies found[/yellow]")
|
|
71
|
+
raise typer.Exit(0)
|
|
72
|
+
|
|
73
|
+
if output == OutputFormat.json:
|
|
74
|
+
data = [c.model_dump(mode="json", exclude_none=True) for c in cookie_list]
|
|
75
|
+
print(json.dumps(data, indent=2))
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if output == OutputFormat.line:
|
|
79
|
+
for cookie in cookie_list:
|
|
80
|
+
print(f"{cookie.domain} {cookie.name} {cookie.value}")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
if output == OutputFormat.short:
|
|
84
|
+
table = Table(title=f"Cookies ({len(cookie_list)} found)")
|
|
85
|
+
table.add_column("Domain", style="cyan")
|
|
86
|
+
table.add_column("Name", style="green")
|
|
87
|
+
table.add_column("Value", style="white", max_width=50, overflow="ellipsis")
|
|
88
|
+
|
|
89
|
+
for cookie in cookie_list:
|
|
90
|
+
value_display = (
|
|
91
|
+
cookie.value[:47] + "..." if len(cookie.value) > 50 else cookie.value
|
|
92
|
+
)
|
|
93
|
+
table.add_row(cookie.domain, cookie.name, value_display)
|
|
94
|
+
|
|
95
|
+
console.print(table)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
table = Table(title=f"Cookies ({len(cookie_list)} found)")
|
|
99
|
+
table.add_column("Domain", style="cyan")
|
|
100
|
+
table.add_column("Name", style="green")
|
|
101
|
+
table.add_column("Value", style="white", max_width=40, overflow="ellipsis")
|
|
102
|
+
table.add_column("Expires", style="dim")
|
|
103
|
+
table.add_column("Flags", style="yellow")
|
|
104
|
+
|
|
105
|
+
for cookie in cookie_list:
|
|
106
|
+
value_display = (
|
|
107
|
+
cookie.value[:37] + "..." if len(cookie.value) > 40 else cookie.value
|
|
108
|
+
)
|
|
109
|
+
expires_display = (
|
|
110
|
+
cookie.expires.strftime("%Y-%m-%d") if cookie.expires else "session"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
flags = []
|
|
114
|
+
if cookie.is_secure:
|
|
115
|
+
flags.append("Secure")
|
|
116
|
+
if cookie.is_httponly:
|
|
117
|
+
flags.append("HttpOnly")
|
|
118
|
+
if cookie.same_site:
|
|
119
|
+
flags.append(f"SameSite={cookie.same_site}")
|
|
120
|
+
flags_display = ", ".join(flags) if flags else "-"
|
|
121
|
+
|
|
122
|
+
table.add_row(
|
|
123
|
+
cookie.domain, cookie.name, value_display, expires_display, flags_display
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
console.print(table)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def profiles(
|
|
131
|
+
browser: Browser | None = typer.Option(
|
|
132
|
+
None, "--browser", "-b", help="Filter by browser"
|
|
133
|
+
),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""List available browser profiles."""
|
|
136
|
+
profile_list = []
|
|
137
|
+
|
|
138
|
+
if browser is None or browser == Browser.chrome:
|
|
139
|
+
profile_list.extend(chrome.list_profiles())
|
|
140
|
+
|
|
141
|
+
if browser is None or browser == Browser.safari:
|
|
142
|
+
profile_list.extend(safari.list_profiles())
|
|
143
|
+
|
|
144
|
+
if not profile_list:
|
|
145
|
+
console.print("[yellow]No profiles found[/yellow]")
|
|
146
|
+
raise typer.Exit(0)
|
|
147
|
+
|
|
148
|
+
title = (
|
|
149
|
+
"Browser Profiles" if browser is None else f"{browser.value.title()} Profiles"
|
|
150
|
+
)
|
|
151
|
+
table = Table(title=title)
|
|
152
|
+
table.add_column("Browser", style="cyan")
|
|
153
|
+
table.add_column("Profile Name", style="green")
|
|
154
|
+
table.add_column("Path", style="dim")
|
|
155
|
+
|
|
156
|
+
for p in profile_list:
|
|
157
|
+
table.add_row(p.browser.title(), p.profile_name, p.path)
|
|
158
|
+
|
|
159
|
+
console.print(table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
app()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# ABOUTME: Pydantic models for cookie data structures
|
|
2
|
+
# ABOUTME: Defines Cookie and browser profile configuration
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, computed_field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Cookie(BaseModel):
|
|
10
|
+
"""Represents a browser cookie with all attributes."""
|
|
11
|
+
|
|
12
|
+
domain: str
|
|
13
|
+
name: str
|
|
14
|
+
value: str
|
|
15
|
+
path: str
|
|
16
|
+
expires: datetime | None = None
|
|
17
|
+
is_secure: bool = False
|
|
18
|
+
is_httponly: bool = False
|
|
19
|
+
same_site: str | None = None
|
|
20
|
+
|
|
21
|
+
@computed_field
|
|
22
|
+
@property
|
|
23
|
+
def is_expired(self) -> bool:
|
|
24
|
+
"""Returns True if the cookie has expired."""
|
|
25
|
+
if self.expires is None:
|
|
26
|
+
return False # Session cookies don't expire
|
|
27
|
+
now = datetime.now(timezone.utc)
|
|
28
|
+
expires = (
|
|
29
|
+
self.expires
|
|
30
|
+
if self.expires.tzinfo
|
|
31
|
+
else self.expires.replace(tzinfo=timezone.utc)
|
|
32
|
+
)
|
|
33
|
+
return expires < now
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return f"{self.name}={self.value}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BrowserProfile(BaseModel):
|
|
40
|
+
"""Represents a browser profile location."""
|
|
41
|
+
|
|
42
|
+
browser: str
|
|
43
|
+
profile_name: str
|
|
44
|
+
path: str
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# ABOUTME: Safari cookie extraction for macOS
|
|
2
|
+
# ABOUTME: Parses the .binarycookies format used by Safari
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .models import BrowserProfile, Cookie
|
|
9
|
+
|
|
10
|
+
# Sandboxed Safari (modern macOS) stores cookies here
|
|
11
|
+
SAFARI_COOKIES_PATH_SANDBOXED = (
|
|
12
|
+
Path.home()
|
|
13
|
+
/ "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"
|
|
14
|
+
)
|
|
15
|
+
# Legacy location for older macOS versions
|
|
16
|
+
SAFARI_COOKIES_PATH_LEGACY = Path.home() / "Library/Cookies/Cookies.binarycookies"
|
|
17
|
+
|
|
18
|
+
# Safari uses Mac absolute time (seconds since Jan 1, 2001)
|
|
19
|
+
MAC_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
|
|
20
|
+
|
|
21
|
+
# SameSite is encoded in bits 3-5 of the flags field
|
|
22
|
+
# https://gist.github.com/creachadair/ba843bd92c2cfc78dc5e1a53b44775a3
|
|
23
|
+
SAME_SITE_MAP: dict[int, str | None] = {
|
|
24
|
+
0: None, # unspecified
|
|
25
|
+
4: "none", # 0b100
|
|
26
|
+
5: "lax", # 0b101
|
|
27
|
+
7: "strict", # 0b111
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_cookies_path() -> Path | None:
|
|
32
|
+
"""Returns the path to Safari's cookies file, or None if not found."""
|
|
33
|
+
for path in [SAFARI_COOKIES_PATH_SANDBOXED, SAFARI_COOKIES_PATH_LEGACY]:
|
|
34
|
+
if path.exists():
|
|
35
|
+
return path
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_profiles() -> list[BrowserProfile]:
|
|
40
|
+
"""Safari only has one profile."""
|
|
41
|
+
cookies_path = _get_cookies_path()
|
|
42
|
+
if cookies_path is None:
|
|
43
|
+
return []
|
|
44
|
+
return [
|
|
45
|
+
BrowserProfile(
|
|
46
|
+
browser="safari",
|
|
47
|
+
profile_name="Default",
|
|
48
|
+
path=str(cookies_path),
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mac_time_to_datetime(mac_time: float) -> datetime | None:
|
|
54
|
+
"""Converts Mac absolute time to datetime."""
|
|
55
|
+
if mac_time == 0:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return MAC_EPOCH + timedelta(seconds=mac_time)
|
|
59
|
+
except (ValueError, OverflowError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_cstring(data: bytes, offset: int) -> str:
|
|
64
|
+
"""Reads a null-terminated C string from data."""
|
|
65
|
+
end = data.index(b"\x00", offset)
|
|
66
|
+
return data[offset:end].decode("utf-8", errors="replace")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_cookie(data: bytes) -> Cookie | None:
|
|
70
|
+
"""Parses a single cookie from binary data."""
|
|
71
|
+
try:
|
|
72
|
+
# Cookie structure:
|
|
73
|
+
# 4 bytes: cookie size
|
|
74
|
+
# 4 bytes: unknown
|
|
75
|
+
# 4 bytes: flags
|
|
76
|
+
# 4 bytes: unknown
|
|
77
|
+
# 4 bytes: domain offset
|
|
78
|
+
# 4 bytes: name offset
|
|
79
|
+
# 4 bytes: path offset
|
|
80
|
+
# 4 bytes: value offset
|
|
81
|
+
# 8 bytes: end of cookie
|
|
82
|
+
# 8 bytes: expiration date (double)
|
|
83
|
+
# 8 bytes: creation date (double)
|
|
84
|
+
|
|
85
|
+
if len(data) < 48:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
flags = struct.unpack("<I", data[8:12])[0]
|
|
89
|
+
domain_offset = struct.unpack("<I", data[16:20])[0]
|
|
90
|
+
name_offset = struct.unpack("<I", data[20:24])[0]
|
|
91
|
+
path_offset = struct.unpack("<I", data[24:28])[0]
|
|
92
|
+
value_offset = struct.unpack("<I", data[28:32])[0]
|
|
93
|
+
expiration = struct.unpack("<d", data[40:48])[0]
|
|
94
|
+
|
|
95
|
+
domain = _read_cstring(data, domain_offset)
|
|
96
|
+
name = _read_cstring(data, name_offset)
|
|
97
|
+
path = _read_cstring(data, path_offset)
|
|
98
|
+
value = _read_cstring(data, value_offset)
|
|
99
|
+
|
|
100
|
+
is_secure = bool(flags & 0x1)
|
|
101
|
+
is_httponly = bool(flags & 0x4)
|
|
102
|
+
same_site_bits = (flags >> 3) & 0x7
|
|
103
|
+
same_site = SAME_SITE_MAP.get(same_site_bits)
|
|
104
|
+
|
|
105
|
+
return Cookie(
|
|
106
|
+
domain=domain,
|
|
107
|
+
name=name,
|
|
108
|
+
value=value,
|
|
109
|
+
path=path,
|
|
110
|
+
expires=_mac_time_to_datetime(expiration),
|
|
111
|
+
is_secure=is_secure,
|
|
112
|
+
is_httponly=is_httponly,
|
|
113
|
+
same_site=same_site,
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_cookies(
|
|
120
|
+
domain: str | None = None,
|
|
121
|
+
profile: str = "Default", # Safari only has one profile, ignored
|
|
122
|
+
) -> list[Cookie]:
|
|
123
|
+
"""
|
|
124
|
+
Reads cookies from Safari's binarycookies file.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
domain: If specified, only return cookies matching this domain.
|
|
128
|
+
profile: Ignored for Safari (only one profile).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of Cookie objects.
|
|
132
|
+
"""
|
|
133
|
+
cookies_path = _get_cookies_path()
|
|
134
|
+
if cookies_path is None:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
with open(cookies_path, "rb") as f:
|
|
138
|
+
data = f.read()
|
|
139
|
+
|
|
140
|
+
cookies: list[Cookie] = []
|
|
141
|
+
|
|
142
|
+
# File format:
|
|
143
|
+
# 4 bytes: magic "cook"
|
|
144
|
+
# 4 bytes: number of pages (big endian)
|
|
145
|
+
# 4 bytes * num_pages: page sizes (big endian)
|
|
146
|
+
# pages follow...
|
|
147
|
+
|
|
148
|
+
if len(data) < 8 or data[:4] != b"cook":
|
|
149
|
+
return cookies
|
|
150
|
+
|
|
151
|
+
num_pages = struct.unpack(">I", data[4:8])[0]
|
|
152
|
+
page_sizes = []
|
|
153
|
+
|
|
154
|
+
offset = 8
|
|
155
|
+
for _ in range(num_pages):
|
|
156
|
+
page_sizes.append(struct.unpack(">I", data[offset : offset + 4])[0])
|
|
157
|
+
offset += 4
|
|
158
|
+
|
|
159
|
+
# Parse each page
|
|
160
|
+
for page_size in page_sizes:
|
|
161
|
+
page_data = data[offset : offset + page_size]
|
|
162
|
+
offset += page_size
|
|
163
|
+
|
|
164
|
+
if len(page_data) < 8:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Page header:
|
|
168
|
+
# 4 bytes: page header (0x00000100)
|
|
169
|
+
# 4 bytes: number of cookies in page
|
|
170
|
+
# 4 bytes * num_cookies: cookie offsets
|
|
171
|
+
|
|
172
|
+
num_cookies = struct.unpack("<I", page_data[4:8])[0]
|
|
173
|
+
cookie_offsets = []
|
|
174
|
+
|
|
175
|
+
page_offset = 8
|
|
176
|
+
for _ in range(num_cookies):
|
|
177
|
+
cookie_offsets.append(
|
|
178
|
+
struct.unpack("<I", page_data[page_offset : page_offset + 4])[0]
|
|
179
|
+
)
|
|
180
|
+
page_offset += 4
|
|
181
|
+
|
|
182
|
+
# Parse each cookie
|
|
183
|
+
for i, cookie_offset in enumerate(cookie_offsets):
|
|
184
|
+
# Determine cookie size
|
|
185
|
+
if i + 1 < len(cookie_offsets):
|
|
186
|
+
cookie_size = cookie_offsets[i + 1] - cookie_offset
|
|
187
|
+
else:
|
|
188
|
+
cookie_size = page_size - cookie_offset
|
|
189
|
+
|
|
190
|
+
cookie_data = page_data[cookie_offset : cookie_offset + cookie_size]
|
|
191
|
+
cookie = _parse_cookie(cookie_data)
|
|
192
|
+
|
|
193
|
+
if cookie:
|
|
194
|
+
if domain is None or domain.lower() in cookie.domain.lower():
|
|
195
|
+
cookies.append(cookie)
|
|
196
|
+
|
|
197
|
+
return cookies
|