whatileaked 0.3.1 → 0.5.0

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.
Files changed (3) hide show
  1. package/README.md +30 -11
  2. package/dist/cli.js +220 -48
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -105,6 +105,17 @@ same credential always produces the same code, so two rows sharing a code are
105
105
  one key you reused in two places, and two different codes are two different
106
106
  keys.
107
107
 
108
+ ### Sharing it
109
+
110
+ The last line of every scan is made only of counts — no project, path, rule or
111
+ fingerprint — so it is the part you can paste or screenshot anywhere:
112
+
113
+ ```
114
+ whatileaked: 3 credentials sent to a model provider, across 561 transcripts.
115
+ ```
116
+
117
+ A clean scan ends the same way, with `no credentials`.
118
+
108
119
  ---
109
120
 
110
121
  ## Is it a real key, or just a test fixture?
@@ -194,18 +205,26 @@ npx whatileaked scan || echo "found something"
194
205
 
195
206
  | Agent | Folder |
196
207
  | --- | --- |
197
- | Claude Code | `~/.claude/projects/` · `~/.claude/CLAUDE.md` |
198
- | Codex | `~/.codex/sessions/` · `~/.codex/archived_sessions/` · `~/.codex/AGENTS.md` |
208
+ | Claude Code | `~/.claude/projects/` · `~/.claude/history.jsonl` · `~/.claude/paste-cache/` · `~/.claude/CLAUDE.md` |
209
+ | Codex | `~/.codex/sessions/` · `~/.codex/archived_sessions/` · `~/.codex/history.jsonl` · `~/.codex/AGENTS.md` |
199
210
  | Cursor | `~/.cursor/projects/*/agent-transcripts/` |
200
-
201
- Cursor keeps its older Ask-mode chats in SQLite rather than jsonl, and those are
202
- not scanned. Reading them needs `node:sqlite`, which arrived in Node 22.5, and
203
- this runs on Node 20. Agent transcripts are covered; Ask history is not.
204
-
205
- Transcripts record credentials that were already sent. Instruction and memory
206
- files are worse: they are read again at the start of the next session, so a
207
- credential in one keeps leaking until the file is edited. Both are scanned, and
208
- `wipe` rewrites both.
211
+ | opencode | `~/.local/share/opencode/opencode.db` |
212
+
213
+ opencode keeps its sessions in a SQLite database. Reading it needs Node's
214
+ built-in `node:sqlite`, which loads without a flag from Node 22.13. On an older
215
+ Node the scan still finds the file and says, in one line, that it was not
216
+ scanned, so a skipped database never looks clean. Only the messages table is
217
+ read, and `wipe` does not rewrite the database: delete those sessions in
218
+ opencode, and rotate.
219
+
220
+ Cursor keeps its older Ask-mode chats in SQLite as well, and those are not
221
+ scanned yet. Agent transcripts are covered; Ask history is not.
222
+
223
+ Transcripts record credentials that were already sent. So does prompt history,
224
+ which keeps every prompt you typed and every long paste after the session itself
225
+ has been cleaned up. Instruction and memory files are worse: they are read again
226
+ at the start of the next session, so a credential in one keeps leaking until the
227
+ file is edited. All of them are scanned, and `wipe` rewrites all of them.
209
228
 
210
229
  Nowhere else. It does not read your repos, your shell history, or your
211
230
  environment. Adding support for another agent is one small file — pull requests
package/dist/cli.js CHANGED
@@ -161,7 +161,10 @@ var TerminalReporter = class {
161
161
  if (totals.length === 0) {
162
162
  this.#write(green("No credentials found."));
163
163
  this.#write("");
164
+ this.#unread(stats);
165
+ if (stats.unread.length > 0) this.#write("");
164
166
  this.#write(dim(DISCLOSURE));
167
+ this.#share(sent, stats);
165
168
  return;
166
169
  }
167
170
  for (const total of totals) {
@@ -190,12 +193,36 @@ var TerminalReporter = class {
190
193
  yellow(`${plural(stats.unscannable, "message")} could not be scanned and are not counted.`)
191
194
  );
192
195
  }
196
+ this.#unread(stats);
193
197
  this.#write("");
194
198
  this.#write(dim("The 8-character code is a fingerprint, never the secret: a truncated"));
195
199
  this.#write(dim("SHA-256, so one credential shows the same code everywhere it leaked."));
196
200
  this.#write(dim("Some findings will be test fixtures \u2014 open the file to tell."));
197
201
  this.#write("");
198
202
  this.#write(dim(DISCLOSURE));
203
+ this.#share(sent, stats);
204
+ }
205
+ /** One plain line per file found and not read. Printed under a clean result
206
+ * too, which is exactly where leaving it out would mislead. */
207
+ #unread(stats) {
208
+ for (const file of stats.unread) {
209
+ this.#write(yellow(`Found ${shorten(file.path)} but did not scan it: ${file.reason}.`));
210
+ }
211
+ }
212
+ /**
213
+ * The one line meant to be pasted somewhere public. Everything above it names
214
+ * projects, paths and fingerprints, so nobody shares it; this is counts only,
215
+ * and stays that way. It is last so a screenshot can crop to it, unstyled so
216
+ * a copy carries nothing but the words, and it repeats the headline's
217
+ * transmitted-only count for the same reason the headline keeps memory files
218
+ * out of it.
219
+ */
220
+ #share(sent, stats) {
221
+ const credentials = sent === 0 ? "no credentials" : plural(sent, "credential");
222
+ this.#write("");
223
+ this.#write(
224
+ `whatileaked: ${credentials} sent to a model provider, across ${plural(stats.transcripts, "transcript")}.`
225
+ );
199
226
  }
200
227
  };
201
228
  function count(value) {
@@ -2381,6 +2408,50 @@ async function* readLines(path) {
2381
2408
  }
2382
2409
  }
2383
2410
 
2411
+ // src/sources/listing.ts
2412
+ import { readdir, realpath, stat } from "fs/promises";
2413
+ import { join } from "path";
2414
+ async function listDirectories(root) {
2415
+ try {
2416
+ const entries = await readdir(root, { withFileTypes: true });
2417
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2418
+ } catch {
2419
+ return [];
2420
+ }
2421
+ }
2422
+ var FileExtension = {
2423
+ jsonl: ".jsonl",
2424
+ markdown: ".md",
2425
+ text: ".txt",
2426
+ database: ".db"
2427
+ };
2428
+ function isJsonl(path) {
2429
+ return path.endsWith(FileExtension.jsonl);
2430
+ }
2431
+ async function listFiles(dir, extension) {
2432
+ try {
2433
+ const entries = await readdir(dir, { withFileTypes: true });
2434
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => entry.name);
2435
+ } catch {
2436
+ return [];
2437
+ }
2438
+ }
2439
+ async function resolveFile(path) {
2440
+ try {
2441
+ const real = await realpath(path);
2442
+ return (await stat(real)).isFile() ? real : null;
2443
+ } catch {
2444
+ return null;
2445
+ }
2446
+ }
2447
+ async function walkDirectories(root) {
2448
+ const found = [root];
2449
+ for (const name of await listDirectories(root)) {
2450
+ found.push(...await walkDirectories(join(root, name)));
2451
+ }
2452
+ return found;
2453
+ }
2454
+
2384
2455
  // src/transcript/reader.ts
2385
2456
  import { createReadStream as createReadStream2 } from "fs";
2386
2457
  import { createInterface as createInterface2 } from "readline";
@@ -2413,6 +2484,7 @@ var TranscriptReader = class {
2413
2484
  };
2414
2485
 
2415
2486
  // src/scan-run.ts
2487
+ var NEEDS_SQLITE = "reading it needs Node 22.13 or newer";
2416
2488
  var ScanRun = class {
2417
2489
  #sources;
2418
2490
  #scanner;
@@ -2433,12 +2505,19 @@ var ScanRun = class {
2433
2505
  memoryFiles: 0,
2434
2506
  entries: 0,
2435
2507
  skipped: 0,
2436
- unscannable: 0
2508
+ unscannable: 0,
2509
+ unread: []
2437
2510
  };
2438
2511
  for (const source of this.#sources) {
2439
2512
  for await (const file of source.discover()) {
2440
- if (file.kind === FileKind.transcript) {
2441
- stats.transcripts++;
2513
+ if (file.messages !== void 0) {
2514
+ await this.#readDatabase(file, file.messages, findings, stats);
2515
+ this.#progress.advance(stats.transcripts + stats.memoryFiles, file.project);
2516
+ continue;
2517
+ }
2518
+ if (file.kind === FileKind.transcript) stats.transcripts++;
2519
+ else stats.memoryFiles++;
2520
+ if (isJsonl(file.path)) {
2442
2521
  const reader = new TranscriptReader();
2443
2522
  for await (const entry of reader.read(file.path)) {
2444
2523
  this.#collect(file, entry.line, entry.index, findings, stats);
@@ -2446,7 +2525,7 @@ var ScanRun = class {
2446
2525
  stats.entries += reader.stats.entries;
2447
2526
  stats.skipped += reader.stats.skipped;
2448
2527
  } else {
2449
- stats.memoryFiles++;
2528
+ if (file.kind === FileKind.transcript) stats.entries++;
2450
2529
  for await (const line of readLines(file.path)) {
2451
2530
  this.#collect(file, line.text, line.number, findings, stats);
2452
2531
  }
@@ -2458,6 +2537,24 @@ var ScanRun = class {
2458
2537
  this.#reporter.report(findings, stats);
2459
2538
  return findings.length;
2460
2539
  }
2540
+ /** A database counts as one transcript once it has been read. One it could
2541
+ * not open is listed instead, so a scan that skipped it never reads as clean. */
2542
+ async #readDatabase(file, messages, findings, stats) {
2543
+ if (messages === null) {
2544
+ stats.unread.push({ path: file.path, reason: NEEDS_SQLITE });
2545
+ return;
2546
+ }
2547
+ let index = 0;
2548
+ try {
2549
+ for await (const message of messages()) {
2550
+ stats.entries++;
2551
+ this.#collect({ ...file, project: message.project }, message.text, index++, findings, stats);
2552
+ }
2553
+ stats.transcripts++;
2554
+ } catch {
2555
+ stats.unread.push({ path: file.path, reason: "it could not be opened" });
2556
+ }
2557
+ }
2461
2558
  /** One entry the engine chokes on must not cost the other 700,000. */
2462
2559
  #collect(file, text, at, findings, stats) {
2463
2560
  let matches = [];
@@ -2485,47 +2582,12 @@ var ScanRun = class {
2485
2582
  // src/sources/claude-code-source.ts
2486
2583
  import { basename, join as join2 } from "path";
2487
2584
 
2488
- // src/sources/listing.ts
2489
- import { readdir, realpath, stat } from "fs/promises";
2490
- import { join } from "path";
2491
- async function listDirectories(root) {
2492
- try {
2493
- const entries = await readdir(root, { withFileTypes: true });
2494
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2495
- } catch {
2496
- return [];
2497
- }
2498
- }
2499
- var FileExtension = { jsonl: ".jsonl", markdown: ".md" };
2500
- async function listFiles(dir, extension) {
2501
- try {
2502
- const entries = await readdir(dir, { withFileTypes: true });
2503
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => entry.name);
2504
- } catch {
2505
- return [];
2506
- }
2507
- }
2508
- async function resolveFile(path) {
2509
- try {
2510
- const real = await realpath(path);
2511
- return (await stat(real)).isFile() ? real : null;
2512
- } catch {
2513
- return null;
2514
- }
2515
- }
2516
- async function walkDirectories(root) {
2517
- const found = [root];
2518
- for (const name of await listDirectories(root)) {
2519
- found.push(...await walkDirectories(join(root, name)));
2520
- }
2521
- return found;
2522
- }
2523
-
2524
2585
  // src/transcript/entry.ts
2525
2586
  var SourceName = {
2526
2587
  "claude-code": "claude-code",
2527
2588
  codex: "codex",
2528
- cursor: "cursor"
2589
+ cursor: "cursor",
2590
+ opencode: "opencode"
2529
2591
  };
2530
2592
 
2531
2593
  // src/transcript/shape.ts
@@ -2553,6 +2615,19 @@ var ClaudeCodeSource = class {
2553
2615
  if (instructions !== null) {
2554
2616
  yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2555
2617
  }
2618
+ const history = await resolveFile(join2(this.#home, ".claude", "history.jsonl"));
2619
+ if (history !== null) {
2620
+ yield { source: this.name, kind: FileKind.transcript, path: history, project: this.name };
2621
+ }
2622
+ const pastes = join2(this.#home, ".claude", "paste-cache");
2623
+ for (const file of await listFiles(pastes, FileExtension.text)) {
2624
+ yield {
2625
+ source: this.name,
2626
+ kind: FileKind.transcript,
2627
+ path: join2(pastes, file),
2628
+ project: this.name
2629
+ };
2630
+ }
2556
2631
  const root = join2(this.#home, ".claude", "projects");
2557
2632
  for (const slug of await listDirectories(root)) {
2558
2633
  const dir = join2(root, slug);
@@ -2606,6 +2681,10 @@ var CodexSource = class {
2606
2681
  if (instructions !== null) {
2607
2682
  yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2608
2683
  }
2684
+ const history = await resolveFile(join3(this.#home, ".codex", "history.jsonl"));
2685
+ if (history !== null) {
2686
+ yield { source: this.name, kind: FileKind.transcript, path: history, project: this.name };
2687
+ }
2609
2688
  for (const root of ["sessions", "archived_sessions"]) {
2610
2689
  for (const dir of await walkDirectories(join3(this.#home, ".codex", root))) {
2611
2690
  for (const file of await listFiles(dir, FileExtension.jsonl)) {
@@ -2689,6 +2768,73 @@ async function isDirectory(path) {
2689
2768
  }
2690
2769
  }
2691
2770
 
2771
+ // src/sources/opencode-source.ts
2772
+ import { basename as basename4, join as join5 } from "path";
2773
+ var OpencodeSource = class {
2774
+ name = SourceName.opencode;
2775
+ #home;
2776
+ #load;
2777
+ constructor(home2, load = loadSqlite) {
2778
+ this.#home = home2;
2779
+ this.#load = load;
2780
+ }
2781
+ async *discover() {
2782
+ const dir = join5(this.#home, ".local", "share", "opencode");
2783
+ for (const file of await listFiles(dir, FileExtension.database)) {
2784
+ const path = join5(dir, file);
2785
+ const sqlite = await this.#load();
2786
+ yield {
2787
+ source: this.name,
2788
+ kind: FileKind.transcript,
2789
+ path,
2790
+ project: this.name,
2791
+ messages: sqlite === null ? null : () => readMessages(sqlite, path, this.name)
2792
+ };
2793
+ }
2794
+ }
2795
+ };
2796
+ async function* readMessages(sqlite, path, fallback) {
2797
+ const db = new sqlite.DatabaseSync(path, { readOnly: true });
2798
+ try {
2799
+ const directories = /* @__PURE__ */ new Map();
2800
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)").all("session", "session_v2");
2801
+ for (const { name } of tables) {
2802
+ for (const row of db.prepare(`SELECT id, directory FROM "${String(name)}"`).iterate()) {
2803
+ if (typeof row.id === "string" && typeof row.directory === "string") {
2804
+ directories.set(row.id, row.directory);
2805
+ }
2806
+ }
2807
+ }
2808
+ for (const row of db.prepare("SELECT session_id, data FROM session_message").iterate()) {
2809
+ if (typeof row.data !== "string") continue;
2810
+ const directory = typeof row.session_id === "string" ? directories.get(row.session_id) : null;
2811
+ yield { project: directory == null ? fallback : basename4(directory), text: row.data };
2812
+ }
2813
+ } finally {
2814
+ db.close();
2815
+ }
2816
+ }
2817
+ var loading = null;
2818
+ function loadSqlite() {
2819
+ loading ??= importSqlite();
2820
+ return loading;
2821
+ }
2822
+ async function importSqlite() {
2823
+ const emitWarning = process.emitWarning;
2824
+ process.emitWarning = ((warning, ...rest) => {
2825
+ if (String(warning).includes("SQLite")) return;
2826
+ Reflect.apply(emitWarning, process, [warning, ...rest]);
2827
+ });
2828
+ try {
2829
+ const specifier = "node:sqlite";
2830
+ return await import(specifier);
2831
+ } catch {
2832
+ return null;
2833
+ } finally {
2834
+ process.emitWarning = emitWarning;
2835
+ }
2836
+ }
2837
+
2692
2838
  // src/wipe/confirm.ts
2693
2839
  import { createInterface as createInterface3 } from "readline/promises";
2694
2840
  var CONFIRMATION = "wipe";
@@ -2732,16 +2878,16 @@ var WipeRun = class {
2732
2878
  this.#write = write;
2733
2879
  }
2734
2880
  async run() {
2735
- const plans = await this.#plan();
2881
+ const { plans, untouched } = await this.#plan();
2736
2882
  if (plans.length === 0) {
2737
- return { planned: [], filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2883
+ return { planned: [], untouched, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2738
2884
  }
2739
2885
  this.#preview(plans);
2740
2886
  const answer = await this.#prompt.ask(
2741
2887
  ` Type ${bold(red(CONFIRMATION))} to rewrite these files, or anything else to stop: `
2742
2888
  );
2743
2889
  if (answer !== CONFIRMATION) {
2744
- return { planned: plans, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2890
+ return { planned: plans, untouched, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2745
2891
  }
2746
2892
  let filesRewritten = 0;
2747
2893
  let secretsRedacted = 0;
@@ -2749,12 +2895,18 @@ var WipeRun = class {
2749
2895
  secretsRedacted += await this.#rewrite(plan.file);
2750
2896
  filesRewritten++;
2751
2897
  }
2752
- return { planned: plans, filesRewritten, secretsRedacted, confirmed: true };
2898
+ return { planned: plans, untouched, filesRewritten, secretsRedacted, confirmed: true };
2753
2899
  }
2754
2900
  async #plan() {
2755
2901
  const plans = [];
2902
+ const untouched = [];
2756
2903
  for (const source of this.#sources) {
2757
2904
  for await (const file of source.discover()) {
2905
+ if (file.messages !== void 0) {
2906
+ if (!await this.#holdsSecret(file.messages)) continue;
2907
+ untouched.push(file.path);
2908
+ continue;
2909
+ }
2758
2910
  let lines = 0;
2759
2911
  let secrets = 0;
2760
2912
  for await (const text of this.#textOf(file)) {
@@ -2768,12 +2920,25 @@ var WipeRun = class {
2768
2920
  }
2769
2921
  }
2770
2922
  }
2771
- return plans;
2923
+ return { plans, untouched };
2924
+ }
2925
+ /** True for a database that cannot be read: it may hold one, and saying
2926
+ * nothing about it would let a wipe pass for complete. */
2927
+ async #holdsSecret(messages) {
2928
+ if (messages == null) return true;
2929
+ try {
2930
+ for await (const message of messages()) {
2931
+ if (this.#scanner.secretsOf(message.text).size > 0) return true;
2932
+ }
2933
+ return false;
2934
+ } catch {
2935
+ return true;
2936
+ }
2772
2937
  }
2773
2938
  /** The rewrite below is textual and kind-blind; only counting needs to know
2774
2939
  * which reader gets the file. */
2775
2940
  async *#textOf(file) {
2776
- if (file.kind === FileKind.memory) {
2941
+ if (!isJsonl(file.path)) {
2777
2942
  for await (const line of readLines(file.path)) yield line.text;
2778
2943
  return;
2779
2944
  }
@@ -2836,7 +3001,8 @@ async function main(argv) {
2836
3001
  const sources = [
2837
3002
  new ClaudeCodeSource(homedir()),
2838
3003
  new CodexSource(homedir()),
2839
- new CursorSource(homedir())
3004
+ new CursorSource(homedir()),
3005
+ new OpencodeSource(homedir())
2840
3006
  ];
2841
3007
  const scanner = new Scanner(new KeywordIndex(compileRules(SECRET_RULE_SOURCES)));
2842
3008
  return command === Command.wipe ? await wipe(sources, scanner, write) : await scan(sources, scanner, write);
@@ -2855,7 +3021,13 @@ async function wipe(sources, scanner, write) {
2855
3021
  write(dim(" replacing credentials in your local transcripts with a placeholder"));
2856
3022
  write("");
2857
3023
  const outcome = await new WipeRun(sources, scanner, new TerminalPrompt(), write).run();
3024
+ for (const path of outcome.untouched) {
3025
+ write(yellow(` Not rewritten: ${shorten(path)} is a database, and wipe only rewrites text.`));
3026
+ write(dim(" Delete those sessions in the agent itself, and rotate what they held."));
3027
+ write("");
3028
+ }
2858
3029
  if (outcome.planned.length === 0) {
3030
+ if (outcome.untouched.length > 0) return 1;
2859
3031
  write(green(" No credentials found. Nothing to wipe."));
2860
3032
  return 0;
2861
3033
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whatileaked",
3
- "version": "0.3.1",
4
- "description": "Scan your local Claude Code, Codex and Cursor transcripts for credentials you already sent to a model provider.",
3
+ "version": "0.5.0",
4
+ "description": "Scan your local Claude Code, Codex, Cursor and opencode transcripts for credentials you already sent to a model provider.",
5
5
  "type": "module",
6
6
  "imports": {
7
7
  "#scripts/*": "./scripts/*.ts",