@lifeaitools/clauth 0.4.1 → 0.5.1

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.
@@ -1,231 +1,231 @@
1
- // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
2
- //
3
- // clauth scrub → scrub active transcript (most recent .jsonl)
4
- // clauth scrub <file> → scrub a specific file
5
- // clauth scrub all → scrub every transcript .jsonl
6
- // clauth scrub --force → rescrub even if already marked
7
-
8
- import fs from "fs";
9
- import path from "path";
10
- import os from "os";
11
- import chalk from "chalk";
12
- import ora from "ora";
13
-
14
- const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
15
- const SCRUB_VERSION = "1.1";
16
-
17
- // ──────────────────────────────────────────────
18
- // Credential patterns — regex + replacement
19
- // ──────────────────────────────────────────────
20
- const PATTERNS = [
21
- // Supabase JWTs (anon, service_role)
22
- [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
23
- "[SUPABASE_JWT_REDACTED]"],
24
-
25
- // Vercel tokens
26
- [/vcp_[A-Za-z0-9]{20,80}/g,
27
- "[VERCEL_TOKEN_REDACTED]"],
28
-
29
- // R2 / S3 secret access keys (64-char hex)
30
- [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g,
31
- '"secret_access_key": "[R2_SECRET_REDACTED]"'],
32
-
33
- // R2 / S3 access key IDs (32-char hex)
34
- [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g,
35
- '"access_key_id": "[R2_KEY_REDACTED]"'],
36
-
37
- // Cloudflare admin tokens
38
- [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g,
39
- '"admin_token": "[CF_TOKEN_REDACTED]"'],
40
-
41
- // Cloudflare account IDs (32-char hex)
42
- [/"account_id"\s*:\s*"[a-f0-9]{32}"/g,
43
- '"account_id": "[CF_ACCOUNT_REDACTED]"'],
44
-
45
- // GitHub tokens
46
- [/ghp_[A-Za-z0-9]{36}/g,
47
- "[GITHUB_TOKEN_REDACTED]"],
48
- [/github_pat_[A-Za-z0-9_]{40,100}/g,
49
- "[GITHUB_PAT_REDACTED]"],
50
-
51
- // Neo4j connection strings with passwords
52
- [/neo4j\+s?:\/\/[^"\\]+/g,
53
- "[NEO4J_CONNSTRING_REDACTED]"],
54
-
55
- // Generic Bearer tokens in Authorization headers
56
- [/Bearer [A-Za-z0-9_-]{20,}/g,
57
- "Bearer [TOKEN_REDACTED]"],
58
- ];
59
-
60
- // ──────────────────────────────────────────────
61
- // Marker check — read last 512 bytes
62
- // ──────────────────────────────────────────────
63
- function isAlreadyScrubbed(filePath) {
64
- try {
65
- const stat = fs.statSync(filePath);
66
- const fd = fs.openSync(filePath, "r");
67
- const bufSize = Math.min(512, stat.size);
68
- const buf = Buffer.alloc(bufSize);
69
- fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
70
- fs.closeSync(fd);
71
-
72
- const tail = buf.toString("utf-8");
73
- const lastLine = tail.trim().split("\n").pop();
74
- if (lastLine && lastLine.includes(SCRUB_MARKER)) {
75
- try {
76
- const marker = JSON.parse(lastLine);
77
- return marker.version === SCRUB_VERSION;
78
- } catch { return false; }
79
- }
80
- return false;
81
- } catch { return false; }
82
- }
83
-
84
- // ──────────────────────────────────────────────
85
- // Stamp file as scrubbed
86
- // ──────────────────────────────────────────────
87
- function stampScrubbed(filePath) {
88
- const marker = JSON.stringify({
89
- type: SCRUB_MARKER,
90
- version: SCRUB_VERSION,
91
- scrubbed_at: new Date().toISOString(),
92
- patterns: PATTERNS.length,
93
- });
94
- fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
95
- }
96
-
97
- // ──────────────────────────────────────────────
98
- // Scrub a single file
99
- // ──────────────────────────────────────────────
100
- function scrubFile(filePath, force = false) {
101
- if (!force && isAlreadyScrubbed(filePath)) {
102
- return "skipped";
103
- }
104
-
105
- let content = fs.readFileSync(filePath, "utf-8");
106
- let total = 0;
107
-
108
- for (const [pattern, replacement] of PATTERNS) {
109
- // Reset lastIndex for global regexes
110
- pattern.lastIndex = 0;
111
- const matches = content.match(pattern);
112
- if (matches) {
113
- total += matches.length;
114
- content = content.replace(pattern, replacement);
115
- }
116
- }
117
-
118
- if (total > 0) {
119
- fs.writeFileSync(filePath, content, "utf-8");
120
- }
121
-
122
- // Stamp as clean (whether creds found or not)
123
- stampScrubbed(filePath);
124
- return total;
125
- }
126
-
127
- // ──────────────────────────────────────────────
128
- // Find all transcript .jsonl files
129
- // ──────────────────────────────────────────────
130
- function findTranscripts() {
131
- const claudeDir = path.join(os.homedir(), ".claude", "projects");
132
- if (!fs.existsSync(claudeDir)) return [];
133
-
134
- const results = [];
135
- function walk(dir) {
136
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
137
- const full = path.join(dir, entry.name);
138
- if (entry.isDirectory()) walk(full);
139
- else if (entry.name.endsWith(".jsonl")) results.push(full);
140
- }
141
- }
142
- walk(claudeDir);
143
- return results;
144
- }
145
-
146
- // ──────────────────────────────────────────────
147
- // Find the most recent transcript (active session)
148
- // ──────────────────────────────────────────────
149
- function findMostRecent() {
150
- const files = findTranscripts();
151
- if (files.length === 0) return null;
152
-
153
- let newest = files[0];
154
- let newestMtime = fs.statSync(files[0]).mtimeMs;
155
- for (const f of files.slice(1)) {
156
- const mt = fs.statSync(f).mtimeMs;
157
- if (mt > newestMtime) { newest = f; newestMtime = mt; }
158
- }
159
- return newest;
160
- }
161
-
162
- // ──────────────────────────────────────────────
163
- // Exported runner
164
- // ──────────────────────────────────────────────
165
- export async function runScrub(target, opts = {}) {
166
- const force = opts.force || false;
167
-
168
- // Determine which files to scrub
169
- let files;
170
- if (target === "all") {
171
- files = findTranscripts();
172
- if (files.length === 0) {
173
- console.log(chalk.yellow("\n No transcript files found.\n"));
174
- return;
175
- }
176
- console.log(chalk.cyan(`\n Scrubbing ${files.length} transcript file(s)...\n`));
177
- } else if (target && target !== "all") {
178
- // Specific file
179
- const resolved = path.resolve(target);
180
- if (!fs.existsSync(resolved)) {
181
- console.log(chalk.red(`\n File not found: ${resolved}\n`));
182
- process.exit(1);
183
- }
184
- files = [resolved];
185
- } else {
186
- // No target — find most recent
187
- const recent = findMostRecent();
188
- if (!recent) {
189
- console.log(chalk.yellow("\n No transcript files found.\n"));
190
- return;
191
- }
192
- files = [recent];
193
- console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
194
- }
195
-
196
- // Scrub
197
- const spinner = ora("Scrubbing credentials...").start();
198
- let grandTotal = 0;
199
- let skipped = 0;
200
- let scanned = 0;
201
-
202
- for (const f of files) {
203
- const result = scrubFile(f, force);
204
- if (result === "skipped") {
205
- skipped++;
206
- } else {
207
- scanned++;
208
- if (result > 0) {
209
- spinner.stop();
210
- console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
211
- spinner.start("Scrubbing credentials...");
212
- grandTotal += result;
213
- }
214
- }
215
- }
216
-
217
- spinner.stop();
218
-
219
- // Summary
220
- const parts = [];
221
- if (scanned) parts.push(`scanned ${scanned}`);
222
- if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
223
- if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
224
- else if (scanned) parts.push(chalk.green("no credentials found"));
225
-
226
- console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
227
- if (skipped && !force) {
228
- console.log(chalk.gray(" (use --force to rescan all files)"));
229
- }
230
- console.log();
231
- }
1
+ // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
2
+ //
3
+ // clauth scrub → scrub active transcript (most recent .jsonl)
4
+ // clauth scrub <file> → scrub a specific file
5
+ // clauth scrub all → scrub every transcript .jsonl
6
+ // clauth scrub --force → rescrub even if already marked
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import os from "os";
11
+ import chalk from "chalk";
12
+ import ora from "ora";
13
+
14
+ const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
15
+ const SCRUB_VERSION = "1.1";
16
+
17
+ // ──────────────────────────────────────────────
18
+ // Credential patterns — regex + replacement
19
+ // ──────────────────────────────────────────────
20
+ const PATTERNS = [
21
+ // Supabase JWTs (anon, service_role)
22
+ [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
23
+ "[SUPABASE_JWT_REDACTED]"],
24
+
25
+ // Vercel tokens
26
+ [/vcp_[A-Za-z0-9]{20,80}/g,
27
+ "[VERCEL_TOKEN_REDACTED]"],
28
+
29
+ // R2 / S3 secret access keys (64-char hex)
30
+ [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g,
31
+ '"secret_access_key": "[R2_SECRET_REDACTED]"'],
32
+
33
+ // R2 / S3 access key IDs (32-char hex)
34
+ [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g,
35
+ '"access_key_id": "[R2_KEY_REDACTED]"'],
36
+
37
+ // Cloudflare admin tokens
38
+ [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g,
39
+ '"admin_token": "[CF_TOKEN_REDACTED]"'],
40
+
41
+ // Cloudflare account IDs (32-char hex)
42
+ [/"account_id"\s*:\s*"[a-f0-9]{32}"/g,
43
+ '"account_id": "[CF_ACCOUNT_REDACTED]"'],
44
+
45
+ // GitHub tokens
46
+ [/ghp_[A-Za-z0-9]{36}/g,
47
+ "[GITHUB_TOKEN_REDACTED]"],
48
+ [/github_pat_[A-Za-z0-9_]{40,100}/g,
49
+ "[GITHUB_PAT_REDACTED]"],
50
+
51
+ // Neo4j connection strings with passwords
52
+ [/neo4j\+s?:\/\/[^"\\]+/g,
53
+ "[NEO4J_CONNSTRING_REDACTED]"],
54
+
55
+ // Generic Bearer tokens in Authorization headers
56
+ [/Bearer [A-Za-z0-9_-]{20,}/g,
57
+ "Bearer [TOKEN_REDACTED]"],
58
+ ];
59
+
60
+ // ──────────────────────────────────────────────
61
+ // Marker check — read last 512 bytes
62
+ // ──────────────────────────────────────────────
63
+ function isAlreadyScrubbed(filePath) {
64
+ try {
65
+ const stat = fs.statSync(filePath);
66
+ const fd = fs.openSync(filePath, "r");
67
+ const bufSize = Math.min(512, stat.size);
68
+ const buf = Buffer.alloc(bufSize);
69
+ fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
70
+ fs.closeSync(fd);
71
+
72
+ const tail = buf.toString("utf-8");
73
+ const lastLine = tail.trim().split("\n").pop();
74
+ if (lastLine && lastLine.includes(SCRUB_MARKER)) {
75
+ try {
76
+ const marker = JSON.parse(lastLine);
77
+ return marker.version === SCRUB_VERSION;
78
+ } catch { return false; }
79
+ }
80
+ return false;
81
+ } catch { return false; }
82
+ }
83
+
84
+ // ──────────────────────────────────────────────
85
+ // Stamp file as scrubbed
86
+ // ──────────────────────────────────────────────
87
+ function stampScrubbed(filePath) {
88
+ const marker = JSON.stringify({
89
+ type: SCRUB_MARKER,
90
+ version: SCRUB_VERSION,
91
+ scrubbed_at: new Date().toISOString(),
92
+ patterns: PATTERNS.length,
93
+ });
94
+ fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
95
+ }
96
+
97
+ // ──────────────────────────────────────────────
98
+ // Scrub a single file
99
+ // ──────────────────────────────────────────────
100
+ function scrubFile(filePath, force = false) {
101
+ if (!force && isAlreadyScrubbed(filePath)) {
102
+ return "skipped";
103
+ }
104
+
105
+ let content = fs.readFileSync(filePath, "utf-8");
106
+ let total = 0;
107
+
108
+ for (const [pattern, replacement] of PATTERNS) {
109
+ // Reset lastIndex for global regexes
110
+ pattern.lastIndex = 0;
111
+ const matches = content.match(pattern);
112
+ if (matches) {
113
+ total += matches.length;
114
+ content = content.replace(pattern, replacement);
115
+ }
116
+ }
117
+
118
+ if (total > 0) {
119
+ fs.writeFileSync(filePath, content, "utf-8");
120
+ }
121
+
122
+ // Stamp as clean (whether creds found or not)
123
+ stampScrubbed(filePath);
124
+ return total;
125
+ }
126
+
127
+ // ──────────────────────────────────────────────
128
+ // Find all transcript .jsonl files
129
+ // ──────────────────────────────────────────────
130
+ function findTranscripts() {
131
+ const claudeDir = path.join(os.homedir(), ".claude", "projects");
132
+ if (!fs.existsSync(claudeDir)) return [];
133
+
134
+ const results = [];
135
+ function walk(dir) {
136
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
137
+ const full = path.join(dir, entry.name);
138
+ if (entry.isDirectory()) walk(full);
139
+ else if (entry.name.endsWith(".jsonl")) results.push(full);
140
+ }
141
+ }
142
+ walk(claudeDir);
143
+ return results;
144
+ }
145
+
146
+ // ──────────────────────────────────────────────
147
+ // Find the most recent transcript (active session)
148
+ // ──────────────────────────────────────────────
149
+ function findMostRecent() {
150
+ const files = findTranscripts();
151
+ if (files.length === 0) return null;
152
+
153
+ let newest = files[0];
154
+ let newestMtime = fs.statSync(files[0]).mtimeMs;
155
+ for (const f of files.slice(1)) {
156
+ const mt = fs.statSync(f).mtimeMs;
157
+ if (mt > newestMtime) { newest = f; newestMtime = mt; }
158
+ }
159
+ return newest;
160
+ }
161
+
162
+ // ──────────────────────────────────────────────
163
+ // Exported runner
164
+ // ──────────────────────────────────────────────
165
+ export async function runScrub(target, opts = {}) {
166
+ const force = opts.force || false;
167
+
168
+ // Determine which files to scrub
169
+ let files;
170
+ if (target === "all") {
171
+ files = findTranscripts();
172
+ if (files.length === 0) {
173
+ console.log(chalk.yellow("\n No transcript files found.\n"));
174
+ return;
175
+ }
176
+ console.log(chalk.cyan(`\n Scrubbing ${files.length} transcript file(s)...\n`));
177
+ } else if (target && target !== "all") {
178
+ // Specific file
179
+ const resolved = path.resolve(target);
180
+ if (!fs.existsSync(resolved)) {
181
+ console.log(chalk.red(`\n File not found: ${resolved}\n`));
182
+ process.exit(1);
183
+ }
184
+ files = [resolved];
185
+ } else {
186
+ // No target — find most recent
187
+ const recent = findMostRecent();
188
+ if (!recent) {
189
+ console.log(chalk.yellow("\n No transcript files found.\n"));
190
+ return;
191
+ }
192
+ files = [recent];
193
+ console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
194
+ }
195
+
196
+ // Scrub
197
+ const spinner = ora("Scrubbing credentials...").start();
198
+ let grandTotal = 0;
199
+ let skipped = 0;
200
+ let scanned = 0;
201
+
202
+ for (const f of files) {
203
+ const result = scrubFile(f, force);
204
+ if (result === "skipped") {
205
+ skipped++;
206
+ } else {
207
+ scanned++;
208
+ if (result > 0) {
209
+ spinner.stop();
210
+ console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
211
+ spinner.start("Scrubbing credentials...");
212
+ grandTotal += result;
213
+ }
214
+ }
215
+ }
216
+
217
+ spinner.stop();
218
+
219
+ // Summary
220
+ const parts = [];
221
+ if (scanned) parts.push(`scanned ${scanned}`);
222
+ if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
223
+ if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
224
+ else if (scanned) parts.push(chalk.green("no credentials found"));
225
+
226
+ console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
227
+ if (skipped && !force) {
228
+ console.log(chalk.gray(" (use --force to rescan all files)"));
229
+ }
230
+ console.log();
231
+ }