chrome-tools 1.1.8 → 1.1.10

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 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)).action((opts, cmd) => dispatch(handlePasswords, scopeFromCli(cmd)));
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))
@@ -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.
@@ -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.
@@ -25,13 +47,36 @@ export function queryDatabase(dbFilePath, sql, params = []) {
25
47
  sourceDb = new Database(dbFilePath, {
26
48
  readonly: true,
27
49
  fileMustExist: true,
28
- timeout: 5000,
50
+ // No wait. A running Chrome holds these stores in `locking_mode =
51
+ // EXCLUSIVE` for as long as it is open, so a busy timeout here buys
52
+ // nothing that waiting longer would win — the lock is not transient, and
53
+ // the copy below is the answer to it either way. Five seconds *per store*
54
+ // was the whole cost of reading a saved login while the browser was up:
55
+ // two stores, ten seconds, spent to arrive at the fallback that then
56
+ // succeeded instantly.
57
+ timeout: 0,
29
58
  });
30
59
  sourceDb.prepare("VACUUM INTO ?").run(tmpDb);
31
60
  }
32
61
  catch (err) {
33
- cleanupTmp(tmpDir);
34
- throw new Error(`Failed to snapshot database file '${basename(dbFilePath)}': ${err.message}`);
62
+ // A running Chrome holds its stores in `locking_mode = EXCLUSIVE`, which
63
+ // refuses every other connection — SQLite cannot even open them to read.
64
+ // That is not a corrupt or missing database, it is a browser that happens
65
+ // to be open, and it made a profile with saved passwords in it report none.
66
+ // Falling back to the file itself is what a locked store leaves: the OS
67
+ // copies bytes regardless of an advisory lock the copier never takes.
68
+ if (!isLocked(err)) {
69
+ cleanupTmp(tmpDir);
70
+ throw new Error(`Failed to snapshot database file '${basename(dbFilePath)}': ${err.message}`);
71
+ }
72
+ try {
73
+ copyLockedDatabase(dbFilePath, tmpDb);
74
+ }
75
+ catch (copyErr) {
76
+ cleanupTmp(tmpDir);
77
+ throw new Error(`Failed to snapshot database file '${basename(dbFilePath)}': ${err.message} ` +
78
+ `(copying it instead also failed: ${copyErr.message})`);
79
+ }
35
80
  }
36
81
  finally {
37
82
  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,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { discoverProfiles, resolveTargetProfiles, } from "../core/chrome.js";
@@ -54,6 +54,75 @@ function configuredAutomationDir() {
54
54
  return undefined;
55
55
  }
56
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
+ }
57
126
  /**
58
127
  * Extracts and decrypts saved passwords from Chrome's password databases.
59
128
  * Reads both the device-local and account-synced stores, labelling each entry
@@ -64,16 +133,7 @@ function configuredAutomationDir() {
64
133
  */
65
134
  export function getPasswords({ chromeDir, profilePath, profile, search, browser, decryptionKey, } = {}) {
66
135
  const userDataDir = profilePath || chromeDir;
67
- // The automation User Data directory first, then the everyday one. A Chrome
68
- // driven over CDP keeps its own profile tree, so a login saved while signing
69
- // in to that window is stored there and nowhere else — looking only at the
70
- // everyday tree could not find it. Naming a directory or a browser answers
71
- // "which tree?" outright, and is used as given.
72
- const roots = [];
73
- const automation = userDataDir || browser ? undefined : automationChromeDir();
74
- if (automation)
75
- roots.push(automation);
76
- roots.push(userDataDir);
136
+ const roots = passwordRoots(userDataDir, browser);
77
137
  const results = [];
78
138
  const term = search?.toLowerCase();
79
139
  // A named profile missing from one tree is not a failure while another is
@@ -100,7 +160,21 @@ export function getPasswords({ chromeDir, profilePath, profile, search, browser,
100
160
  const rows = queryDatabase(dbPath, `SELECT origin_url, username_value, password_value, date_last_used
101
161
  FROM logins ORDER BY origin_url`);
102
162
  for (const row of rows) {
103
- const entry = {
163
+ // Matched before anything is decrypted. Both columns the search
164
+ // looks at — the origin and the username — are stored in the clear,
165
+ // so a row that cannot match never needs its password read at all.
166
+ //
167
+ // Decrypting first cost one platform decryption per stored login on
168
+ // the way to finding one: a store with a few hundred entries spent
169
+ // upwards of ten seconds answering "what is the password for this
170
+ // one site", and every one of those decryptions was thrown away by
171
+ // the filter immediately below.
172
+ if (term &&
173
+ !(row.origin_url && row.origin_url.toLowerCase().includes(term)) &&
174
+ !(row.username_value && row.username_value.toLowerCase().includes(term))) {
175
+ continue;
176
+ }
177
+ results.push({
104
178
  profile: p.name,
105
179
  source: store.source,
106
180
  url: row.origin_url,
@@ -109,14 +183,7 @@ export function getPasswords({ chromeDir, profilePath, profile, search, browser,
109
183
  ? decryptValue(row.password_value, derivedKey ??= deriveEncryptionKey(root, browser))
110
184
  : "",
111
185
  lastUsed: chromeTimeToDate(row.date_last_used || 0),
112
- };
113
- if (term) {
114
- if (!(entry.url && entry.url.toLowerCase().includes(term)) &&
115
- !(entry.username && entry.username.toLowerCase().includes(term))) {
116
- continue;
117
- }
118
- }
119
- results.push(entry);
186
+ });
120
187
  }
121
188
  }
122
189
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-tools",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "CLI & Node.js library for chrome",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",