chrome-tools 1.1.7 → 1.1.9
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.
- package/dist/bin/cli.js +35 -2
- package/dist/src/core/db.d.ts +2 -1
- package/dist/src/core/db.js +42 -4
- package/dist/src/modules/passwords.d.ts +24 -0
- package/dist/src/modules/passwords.js +156 -29
- package/package.json +1 -1
package/dist/bin/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import chalk from "chalk";
|
|
|
12
12
|
import Table from "cli-table3";
|
|
13
13
|
import { discoverProfiles, getProfileMetadata, getProfileStates, getDirectorySize, detectInstalledBrowsers } from "../src/core/chrome.js";
|
|
14
14
|
import { setActiveBrowser, getActiveBrowser, isKeychainAccessDenied } from "../src/core/platform/index.js";
|
|
15
|
-
import { getPasswords } from "../src/modules/passwords.js";
|
|
15
|
+
import { describePasswordStores, getPasswords } from "../src/modules/passwords.js";
|
|
16
16
|
import { getBookmarks } from "../src/modules/bookmarks.js";
|
|
17
17
|
import { getHistory, getSearchTerms } from "../src/modules/history.js";
|
|
18
18
|
import { getCookies } from "../src/modules/cookies.js";
|
|
@@ -364,8 +364,12 @@ function handleProfileStates(opts) {
|
|
|
364
364
|
}
|
|
365
365
|
// ─── Passwords Command ──────────────────────────────────────────────────────
|
|
366
366
|
const passwordsCmd = program.command("passwords").description("List saved passwords");
|
|
367
|
-
addSearchOption(addCommonOptions(passwordsCmd))
|
|
367
|
+
addSearchOption(addCommonOptions(passwordsCmd))
|
|
368
|
+
.option("--diagnose", "Report the stores that would be read — profile, file, row count, and any read error — instead of the passwords themselves")
|
|
369
|
+
.action((opts, cmd) => dispatch(handlePasswords, scopeFromCli(cmd)));
|
|
368
370
|
function handlePasswords(opts) {
|
|
371
|
+
if (opts.diagnose)
|
|
372
|
+
return diagnosePasswords(opts);
|
|
369
373
|
const results = getPasswords(opts);
|
|
370
374
|
if (emit(opts, results))
|
|
371
375
|
return;
|
|
@@ -398,6 +402,35 @@ function handlePasswords(opts) {
|
|
|
398
402
|
console.log(table.toString());
|
|
399
403
|
printCount("password(s)", results.length);
|
|
400
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Explains an empty listing. A store that cannot be opened is skipped silently
|
|
407
|
+
* during a normal read, which leaves "no passwords found" meaning either
|
|
408
|
+
* "none saved" or "none readable" — this separates the two. Counts and errors
|
|
409
|
+
* only: nothing here is decrypted.
|
|
410
|
+
*/
|
|
411
|
+
function diagnosePasswords(opts) {
|
|
412
|
+
const reports = describePasswordStores(opts);
|
|
413
|
+
if (emit(opts, reports))
|
|
414
|
+
return;
|
|
415
|
+
if (reports.length === 0) {
|
|
416
|
+
console.log(chalk.yellow("\n⚠ No profiles found to read.\n"));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
console.log(chalk.bold("\n🔎 Password stores:\n"));
|
|
420
|
+
const table = makeTable(["Root", "Profile", "Store", "Exists", "Rows", "Error"], [34, 12, 24, 7, 6, 40]);
|
|
421
|
+
for (const r of reports) {
|
|
422
|
+
table.push([
|
|
423
|
+
chalk.dim(r.root),
|
|
424
|
+
r.profile,
|
|
425
|
+
chalk.blue(r.file),
|
|
426
|
+
r.exists ? "yes" : chalk.yellow("no"),
|
|
427
|
+
r.rows === null ? chalk.red("?") : String(r.rows),
|
|
428
|
+
r.error ? chalk.red(r.error) : "",
|
|
429
|
+
]);
|
|
430
|
+
}
|
|
431
|
+
console.log(table.toString());
|
|
432
|
+
console.log();
|
|
433
|
+
}
|
|
401
434
|
// ─── Bookmarks Command ──────────────────────────────────────────────────────
|
|
402
435
|
const bookmarksCmd = program.command("bookmarks").description("List bookmarks");
|
|
403
436
|
addSearchOption(addCommonOptions(bookmarksCmd))
|
package/dist/src/core/db.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Creates a transactionally consistent snapshot of a Chrome SQLite database,
|
|
3
3
|
* executes a query against it, and cleans up. `VACUUM INTO` reads through
|
|
4
4
|
* SQLite itself, so live WAL contents are included without racing raw file
|
|
5
|
-
* copies while Chrome is writing.
|
|
5
|
+
* copies while Chrome is writing. A store the browser holds exclusively cannot
|
|
6
|
+
* be opened that way at all, and falls back to a file copy (see below).
|
|
6
7
|
*
|
|
7
8
|
* @param dbFilePath - Absolute path to the Chrome database file.
|
|
8
9
|
* @param sql - SQL query to execute.
|
package/dist/src/core/db.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, basename } from "node:path";
|
|
4
4
|
import Database from "better-sqlite3";
|
|
5
|
+
/** Whether a failure is "another process owns this database", not a broken one. */
|
|
6
|
+
function isLocked(err) {
|
|
7
|
+
const code = err?.code;
|
|
8
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9
|
+
return ((typeof code === "string" && code.startsWith("SQLITE_BUSY")) ||
|
|
10
|
+
/database is locked|database is busy/i.test(message));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Copies a database SQLite will not open, alongside the sidecars that make it
|
|
14
|
+
* readable: `-wal` holds commits not yet folded into the main file, and `-shm`
|
|
15
|
+
* is its index. Copying the main file alone would silently drop the most recent
|
|
16
|
+
* writes — a password saved a minute ago being exactly the kind that matters.
|
|
17
|
+
*/
|
|
18
|
+
function copyLockedDatabase(dbFilePath, tmpDb) {
|
|
19
|
+
copyFileSync(dbFilePath, tmpDb);
|
|
20
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
21
|
+
const sidecar = `${dbFilePath}${suffix}`;
|
|
22
|
+
if (existsSync(sidecar))
|
|
23
|
+
copyFileSync(sidecar, `${tmpDb}${suffix}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
5
26
|
/**
|
|
6
27
|
* Creates a transactionally consistent snapshot of a Chrome SQLite database,
|
|
7
28
|
* executes a query against it, and cleans up. `VACUUM INTO` reads through
|
|
8
29
|
* SQLite itself, so live WAL contents are included without racing raw file
|
|
9
|
-
* copies while Chrome is writing.
|
|
30
|
+
* copies while Chrome is writing. A store the browser holds exclusively cannot
|
|
31
|
+
* be opened that way at all, and falls back to a file copy (see below).
|
|
10
32
|
*
|
|
11
33
|
* @param dbFilePath - Absolute path to the Chrome database file.
|
|
12
34
|
* @param sql - SQL query to execute.
|
|
@@ -30,8 +52,24 @@ export function queryDatabase(dbFilePath, sql, params = []) {
|
|
|
30
52
|
sourceDb.prepare("VACUUM INTO ?").run(tmpDb);
|
|
31
53
|
}
|
|
32
54
|
catch (err) {
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
// A running Chrome holds its stores in `locking_mode = EXCLUSIVE`, which
|
|
56
|
+
// refuses every other connection — SQLite cannot even open them to read.
|
|
57
|
+
// That is not a corrupt or missing database, it is a browser that happens
|
|
58
|
+
// to be open, and it made a profile with saved passwords in it report none.
|
|
59
|
+
// Falling back to the file itself is what a locked store leaves: the OS
|
|
60
|
+
// copies bytes regardless of an advisory lock the copier never takes.
|
|
61
|
+
if (!isLocked(err)) {
|
|
62
|
+
cleanupTmp(tmpDir);
|
|
63
|
+
throw new Error(`Failed to snapshot database file '${basename(dbFilePath)}': ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
copyLockedDatabase(dbFilePath, tmpDb);
|
|
67
|
+
}
|
|
68
|
+
catch (copyErr) {
|
|
69
|
+
cleanupTmp(tmpDir);
|
|
70
|
+
throw new Error(`Failed to snapshot database file '${basename(dbFilePath)}': ${err.message} ` +
|
|
71
|
+
`(copying it instead also failed: ${copyErr.message})`);
|
|
72
|
+
}
|
|
35
73
|
}
|
|
36
74
|
finally {
|
|
37
75
|
if (sourceDb) {
|
|
@@ -15,6 +15,30 @@ export interface PasswordEntry {
|
|
|
15
15
|
lastUsed?: string;
|
|
16
16
|
error?: string;
|
|
17
17
|
}
|
|
18
|
+
/** What a single password store looked like when it was read. */
|
|
19
|
+
export interface PasswordStoreReport {
|
|
20
|
+
/** The User Data directory searched; "default" when the platform picked it. */
|
|
21
|
+
root: string;
|
|
22
|
+
profile: string;
|
|
23
|
+
/** "Login Data" (device) or "Login Data For Account" (synced). */
|
|
24
|
+
file: string;
|
|
25
|
+
source: PasswordSource;
|
|
26
|
+
exists: boolean;
|
|
27
|
+
/** Rows in `logins`, before any search filter. Null when the read failed. */
|
|
28
|
+
rows: number | null;
|
|
29
|
+
/** Why the read failed, when it did. No credential value is ever included. */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reports what a `getPasswords` call would read, and what it could not.
|
|
34
|
+
*
|
|
35
|
+
* `getPasswords` skips a store it cannot open — a missing file, a schema it does
|
|
36
|
+
* not know, a database another process is holding — and an empty result cannot
|
|
37
|
+
* tell "nothing saved here" from "could not look". This runs the same profile
|
|
38
|
+
* resolution and opens the same databases, and answers with row counts and
|
|
39
|
+
* errors only. It decrypts nothing, so no password value can escape through it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function describePasswordStores({ chromeDir, profilePath, profile, browser, }?: PasswordOptions): PasswordStoreReport[];
|
|
18
42
|
/**
|
|
19
43
|
* Extracts and decrypts saved passwords from Chrome's password databases.
|
|
20
44
|
* Reads both the device-local and account-synced stores, labelling each entry
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
1
3
|
import { join } from "node:path";
|
|
2
|
-
import { resolveTargetProfiles } from "../core/chrome.js";
|
|
4
|
+
import { discoverProfiles, resolveTargetProfiles, } from "../core/chrome.js";
|
|
3
5
|
import { deriveEncryptionKey, decryptValue } from "../core/platform/index.js";
|
|
4
6
|
import { chromeTimeToDate } from "../core/crypto.js";
|
|
5
7
|
import { queryDatabase } from "../core/db.js";
|
|
@@ -14,6 +16,113 @@ const PASSWORD_STORES = [
|
|
|
14
16
|
{ file: "Login Data", source: "device" },
|
|
15
17
|
{ file: "Login Data For Account", source: "account" },
|
|
16
18
|
];
|
|
19
|
+
/** Where a CDP launcher keeps the User Data directory it drives Chrome with. */
|
|
20
|
+
const AUTOMATION_DIR = join(homedir(), ".chrome_cdp_profile");
|
|
21
|
+
/** …and where it records a relocated one. */
|
|
22
|
+
const AUTOMATION_CONFIG = join(homedir(), ".config", "chrome-cdp-manager", "config.json");
|
|
23
|
+
/** `CHROME_AUTOMATION_DIR` values meaning "do not look there at all". */
|
|
24
|
+
const AUTOMATION_OFF = ["0", "off", "none", "false"];
|
|
25
|
+
/**
|
|
26
|
+
* The automation profile tree, on a machine that has one.
|
|
27
|
+
*
|
|
28
|
+
* Most machines do not: no CDP launcher is installed, the directory was never
|
|
29
|
+
* created, or it exists but holds no profile yet. All three answer undefined,
|
|
30
|
+
* which leaves the search exactly as it was before this existed — the everyday
|
|
31
|
+
* profile, alone. Nothing here is required for a plain Chrome install.
|
|
32
|
+
*
|
|
33
|
+
* `CHROME_AUTOMATION_DIR` names the tree explicitly, and `off` (or `0`, `none`,
|
|
34
|
+
* `false`) skips it even where one exists.
|
|
35
|
+
*/
|
|
36
|
+
function automationChromeDir() {
|
|
37
|
+
const override = process.env.CHROME_AUTOMATION_DIR?.trim();
|
|
38
|
+
if (override && AUTOMATION_OFF.includes(override.toLowerCase()))
|
|
39
|
+
return undefined;
|
|
40
|
+
const dir = override || configuredAutomationDir() || AUTOMATION_DIR;
|
|
41
|
+
// Holding a profile is what makes it a tree worth reading — an absent or
|
|
42
|
+
// empty directory would otherwise add a root every lookup searches for
|
|
43
|
+
// nothing, and turn a missing `--profile` into a spurious refusal.
|
|
44
|
+
return discoverProfiles(dir).length > 0 ? dir : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** The profile directory a CDP launcher was configured with, if it says. */
|
|
47
|
+
function configuredAutomationDir() {
|
|
48
|
+
try {
|
|
49
|
+
const stored = JSON.parse(readFileSync(AUTOMATION_CONFIG, "utf-8"));
|
|
50
|
+
return typeof stored.profileDir === "string" && stored.profileDir ? stored.profileDir : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No launcher config, or an unreadable one: fall back to its default path.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The User Data directories a lookup reads, in order.
|
|
59
|
+
*
|
|
60
|
+
* The automation tree first, then the everyday one. A Chrome driven over CDP
|
|
61
|
+
* keeps its own profile tree, so a login saved while signing in to that window
|
|
62
|
+
* is stored there and nowhere else — looking only at the everyday tree could
|
|
63
|
+
* not find it. Naming a directory or a browser answers "which tree?" outright,
|
|
64
|
+
* and is used as given.
|
|
65
|
+
*/
|
|
66
|
+
function passwordRoots(userDataDir, browser) {
|
|
67
|
+
const roots = [];
|
|
68
|
+
const automation = userDataDir || browser ? undefined : automationChromeDir();
|
|
69
|
+
if (automation)
|
|
70
|
+
roots.push(automation);
|
|
71
|
+
roots.push(userDataDir);
|
|
72
|
+
return roots;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reports what a `getPasswords` call would read, and what it could not.
|
|
76
|
+
*
|
|
77
|
+
* `getPasswords` skips a store it cannot open — a missing file, a schema it does
|
|
78
|
+
* not know, a database another process is holding — and an empty result cannot
|
|
79
|
+
* tell "nothing saved here" from "could not look". This runs the same profile
|
|
80
|
+
* resolution and opens the same databases, and answers with row counts and
|
|
81
|
+
* errors only. It decrypts nothing, so no password value can escape through it.
|
|
82
|
+
*/
|
|
83
|
+
export function describePasswordStores({ chromeDir, profilePath, profile, browser, } = {}) {
|
|
84
|
+
const userDataDir = profilePath || chromeDir;
|
|
85
|
+
const reports = [];
|
|
86
|
+
for (const root of passwordRoots(userDataDir, browser)) {
|
|
87
|
+
let profiles;
|
|
88
|
+
try {
|
|
89
|
+
profiles = resolveTargetProfiles({ chromeDir: root, profile, browser });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
reports.push({
|
|
93
|
+
root: root ?? "default",
|
|
94
|
+
profile: profile ?? "*",
|
|
95
|
+
file: "*",
|
|
96
|
+
source: "device",
|
|
97
|
+
exists: false,
|
|
98
|
+
rows: null,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const p of profiles) {
|
|
104
|
+
for (const store of PASSWORD_STORES) {
|
|
105
|
+
const dbPath = join(p.path, store.file);
|
|
106
|
+
const report = {
|
|
107
|
+
root: root ?? "default",
|
|
108
|
+
profile: p.name,
|
|
109
|
+
file: store.file,
|
|
110
|
+
source: store.source,
|
|
111
|
+
exists: existsSync(dbPath),
|
|
112
|
+
rows: null,
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
report.rows = queryDatabase(dbPath, "SELECT COUNT(*) AS n FROM logins")[0]?.n ?? 0;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
report.error = error instanceof Error ? error.message : String(error);
|
|
119
|
+
}
|
|
120
|
+
reports.push(report);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return reports;
|
|
125
|
+
}
|
|
17
126
|
/**
|
|
18
127
|
* Extracts and decrypts saved passwords from Chrome's password databases.
|
|
19
128
|
* Reads both the device-local and account-synced stores, labelling each entry
|
|
@@ -24,41 +133,59 @@ const PASSWORD_STORES = [
|
|
|
24
133
|
*/
|
|
25
134
|
export function getPasswords({ chromeDir, profilePath, profile, search, browser, decryptionKey, } = {}) {
|
|
26
135
|
const userDataDir = profilePath || chromeDir;
|
|
27
|
-
const
|
|
28
|
-
// Pass user-data dir so Windows reads Local State from the same tree as the profiles.
|
|
29
|
-
let derivedKey = decryptionKey;
|
|
136
|
+
const roots = passwordRoots(userDataDir, browser);
|
|
30
137
|
const results = [];
|
|
31
138
|
const term = search?.toLowerCase();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
139
|
+
// A named profile missing from one tree is not a failure while another is
|
|
140
|
+
// still to come — but a profile in none of them is still an error, so the
|
|
141
|
+
// first refusal is kept and raised if no tree resolved at all.
|
|
142
|
+
let refusal;
|
|
143
|
+
let resolved = false;
|
|
144
|
+
for (const root of roots) {
|
|
145
|
+
let profiles;
|
|
146
|
+
try {
|
|
147
|
+
profiles = resolveTargetProfiles({ chromeDir: root, profile, browser });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
refusal ??= error;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
resolved = true;
|
|
154
|
+
// Per root: on Windows the key comes from that tree's own Local State.
|
|
155
|
+
let derivedKey = decryptionKey;
|
|
156
|
+
for (const p of profiles) {
|
|
157
|
+
for (const store of PASSWORD_STORES) {
|
|
158
|
+
const dbPath = join(p.path, store.file);
|
|
159
|
+
try {
|
|
160
|
+
const rows = queryDatabase(dbPath, `SELECT origin_url, username_value, password_value, date_last_used
|
|
161
|
+
FROM logins ORDER BY origin_url`);
|
|
162
|
+
for (const row of rows) {
|
|
163
|
+
const entry = {
|
|
164
|
+
profile: p.name,
|
|
165
|
+
source: store.source,
|
|
166
|
+
url: row.origin_url,
|
|
167
|
+
username: row.username_value,
|
|
168
|
+
password: row.password_value?.length > 0
|
|
169
|
+
? decryptValue(row.password_value, derivedKey ??= deriveEncryptionKey(root, browser))
|
|
170
|
+
: "",
|
|
171
|
+
lastUsed: chromeTimeToDate(row.date_last_used || 0),
|
|
172
|
+
};
|
|
173
|
+
if (term) {
|
|
174
|
+
if (!(entry.url && entry.url.toLowerCase().includes(term)) &&
|
|
175
|
+
!(entry.username && entry.username.toLowerCase().includes(term))) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
53
178
|
}
|
|
179
|
+
results.push(entry);
|
|
54
180
|
}
|
|
55
|
-
results.push(entry);
|
|
56
181
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
182
|
+
catch {
|
|
183
|
+
// Skip missing or unreadable stores and continue with the others.
|
|
184
|
+
}
|
|
60
185
|
}
|
|
61
186
|
}
|
|
62
187
|
}
|
|
188
|
+
if (!resolved && refusal)
|
|
189
|
+
throw refusal;
|
|
63
190
|
return results;
|
|
64
191
|
}
|