@lifeaitools/clauth 1.30.6 → 1.30.8

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,17 +1,9 @@
1
- // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
1
+ // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
2
2
  //
3
- // clauth scrub → scrub active transcript (most recent)
3
+ // clauth scrub → scrub active transcript (most recent .jsonl)
4
4
  // clauth scrub <file> → scrub a specific file
5
- // clauth scrub all → scrub every transcript + tool-result sidecar
5
+ // clauth scrub all → scrub every transcript .jsonl
6
6
  // clauth scrub --force → rescrub even if already marked
7
- //
8
- // Three layers of redaction:
9
- // 1. Built-in regex PATTERNS (known token shapes)
10
- // 2. User-editable patterns from ~/.clauth/scrub-patterns.json (no release needed)
11
- // 3. Vault-value redaction — literal occurrences of THIS machine's actual secret
12
- // values, pulled best-effort from the local daemon (covers any format).
13
- // Files scanned: .jsonl transcripts AND tool-results/*.txt sidecars (the sidecars
14
- // were previously skipped — a leaked secret could survive a "clean" scrub there).
15
7
 
16
8
  import fs from "fs";
17
9
  import path from "path";
@@ -20,125 +12,53 @@ import chalk from "chalk";
20
12
  import ora from "ora";
21
13
 
22
14
  const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
23
- const SCRUB_VERSION = "1.2";
24
- const DAEMON = process.env.CLAUTH_DAEMON_URL || "http://127.0.0.1:52437";
25
- const VAULT_FETCH_DELAY_MS = Number(process.env.CLAUTH_SCRUB_FETCH_DELAY_MS || 2200);
26
-
27
- function sleep(ms) {
28
- return new Promise(resolve => setTimeout(resolve, ms));
29
- }
30
-
31
- // Value-based redaction only considers values that look like real secrets, to avoid
32
- // over-redacting short/benign config values (bucket names, plain URLs, etc.).
33
- const MIN_SECRET_LEN = 16;
15
+ const SCRUB_VERSION = "1.1";
34
16
 
35
17
  // ──────────────────────────────────────────────
36
- // Built-in credential patterns — regex + replacement
18
+ // Credential patterns — regex + replacement
37
19
  // ──────────────────────────────────────────────
38
20
  const PATTERNS = [
39
- [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[SUPABASE_JWT_REDACTED]"],
40
- [/vcp_[A-Za-z0-9]{20,80}/g, "[VERCEL_TOKEN_REDACTED]"],
41
- [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g, '"secret_access_key": "[R2_SECRET_REDACTED]"'],
42
- [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g, '"access_key_id": "[R2_KEY_REDACTED]"'],
43
- [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g, '"admin_token": "[CF_TOKEN_REDACTED]"'],
44
- [/"account_id"\s*:\s*"[a-f0-9]{32}"/g, '"account_id": "[CF_ACCOUNT_REDACTED]"'],
45
- // GitHub tokens — ghp_ classic is 36 chars, but accept 30+ to be resilient to format shifts
46
- [/ghp_[A-Za-z0-9]{30,}/g, "[GITHUB_TOKEN_REDACTED]"],
47
- [/gho_[A-Za-z0-9]{30,}/g, "[GITHUB_OAUTH_REDACTED]"],
48
- [/ghs_[A-Za-z0-9]{30,}/g, "[GITHUB_SERVER_REDACTED]"],
49
- [/github_pat_[A-Za-z0-9_]{40,100}/g, "[GITHUB_PAT_REDACTED]"],
50
- // AWS access key IDs
51
- [/AKIA[A-Z0-9]{16}/g, "[AWS_KEY_REDACTED]"],
52
- // OpenAI / Anthropic style keys
53
- [/sk-[A-Za-z0-9_-]{20,}/g, "[API_KEY_REDACTED]"],
54
- [/neo4j\+s?:\/\/[^"\\]+/g, "[NEO4J_CONNSTRING_REDACTED]"],
55
- [/Bearer [A-Za-z0-9_-]{20,}/g, "Bearer [TOKEN_REDACTED]"],
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]"],
56
58
  ];
57
59
 
58
60
  // ──────────────────────────────────────────────
59
- // Layer 2user-editable patterns (no clauth release to add a pattern)
60
- // ~/.clauth/scrub-patterns.json: [{ "pattern": "...", "flags": "g", "replacement": "..." }]
61
- // ──────────────────────────────────────────────
62
- export function loadExtraPatterns(filePath) {
63
- const file = filePath || path.join(os.homedir(), ".clauth", "scrub-patterns.json");
64
- try {
65
- if (!fs.existsSync(file)) return [];
66
- const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
67
- const list = Array.isArray(raw) ? raw : Array.isArray(raw.patterns) ? raw.patterns : [];
68
- const out = [];
69
- for (const entry of list) {
70
- if (!entry || !entry.pattern) continue;
71
- try {
72
- const flags = entry.flags && entry.flags.includes("g") ? entry.flags : (entry.flags || "") + "g";
73
- out.push([new RegExp(entry.pattern, flags), entry.replacement ?? "[REDACTED]"]);
74
- } catch { /* skip malformed pattern, never break scrub */ }
75
- }
76
- return out;
77
- } catch {
78
- return [];
79
- }
80
- }
81
-
82
- // ──────────────────────────────────────────────
83
- // Layer 3 — literal secret values from the vault
84
- // ──────────────────────────────────────────────
85
- export function isSecretLike(value) {
86
- if (typeof value !== "string") return false;
87
- const v = value.trim();
88
- if (v.length < MIN_SECRET_LEN) return false; // too short → likely benign config
89
- if (/\s/.test(v)) return false; // whitespace → not a token (URLs/sentences)
90
- if (/^https?:\/\//i.test(v)) return false; // plain endpoint URL, not a secret
91
- return true;
92
- }
93
-
94
- // Best-effort: pull this machine's actual secret values from the local daemon.
95
- // This is intentionally slow because literal redaction must read real secrets.
96
- // Normal service discovery should use /knowledge and never call this.
97
- // Returns [{ name, value }]. Any failure (daemon down/locked/offline) → [] (regex still runs).
98
- export async function fetchVaultValues(base = DAEMON) {
99
- try {
100
- const ctrl = new AbortController();
101
- const t = setTimeout(() => ctrl.abort(), 4000);
102
- const listRes = await fetch(`${base}/knowledge`, { signal: ctrl.signal });
103
- clearTimeout(t);
104
- if (!listRes.ok) return [];
105
- const listJson = await listRes.json();
106
- const names = (listJson.services || []).filter(s => s.has_key !== false).map(s => s.name);
107
- const out = [];
108
- for (let i = 0; i < names.length; i++) {
109
- const name = names[i];
110
- if (i > 0 && VAULT_FETCH_DELAY_MS > 0) await sleep(VAULT_FETCH_DELAY_MS);
111
- try {
112
- const c = new AbortController();
113
- const tt = setTimeout(() => c.abort(), 4000);
114
- const r = await fetch(`${base}/v/${encodeURIComponent(name)}`, { signal: c.signal });
115
- clearTimeout(tt);
116
- if (!r.ok) continue;
117
- const value = (await r.text()).trim();
118
- if (isSecretLike(value)) out.push({ name, value });
119
- } catch { /* skip this service */ }
120
- }
121
- return out;
122
- } catch {
123
- return [];
124
- }
125
- }
126
-
127
- function redactLiterals(content, literals) {
128
- let count = 0;
129
- for (const { name, value } of literals) {
130
- if (!value || !content.includes(value)) continue;
131
- const before = content.length;
132
- const parts = content.split(value);
133
- count += parts.length - 1;
134
- content = parts.join(`[CLAUTH:${name}_REDACTED]`);
135
- void before;
136
- }
137
- return { content, count };
138
- }
139
-
140
- // ──────────────────────────────────────────────
141
- // Marker check / stamp
61
+ // Marker checkread last 512 bytes
142
62
  // ──────────────────────────────────────────────
143
63
  function isAlreadyScrubbed(filePath) {
144
64
  try {
@@ -148,19 +68,28 @@ function isAlreadyScrubbed(filePath) {
148
68
  const buf = Buffer.alloc(bufSize);
149
69
  fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
150
70
  fs.closeSync(fd);
151
- const lastLine = buf.toString("utf-8").trim().split("\n").pop();
71
+
72
+ const tail = buf.toString("utf-8");
73
+ const lastLine = tail.trim().split("\n").pop();
152
74
  if (lastLine && lastLine.includes(SCRUB_MARKER)) {
153
- try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
75
+ try {
76
+ const marker = JSON.parse(lastLine);
77
+ return marker.version === SCRUB_VERSION;
78
+ } catch { return false; }
154
79
  }
155
80
  return false;
156
81
  } catch { return false; }
157
82
  }
158
83
 
84
+ // ──────────────────────────────────────────────
85
+ // Stamp file as scrubbed
86
+ // ──────────────────────────────────────────────
159
87
  function stampScrubbed(filePath) {
160
88
  const marker = JSON.stringify({
161
89
  type: SCRUB_MARKER,
162
90
  version: SCRUB_VERSION,
163
91
  scrubbed_at: new Date().toISOString(),
92
+ patterns: PATTERNS.length,
164
93
  });
165
94
  fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
166
95
  }
@@ -168,14 +97,16 @@ function stampScrubbed(filePath) {
168
97
  // ──────────────────────────────────────────────
169
98
  // Scrub a single file
170
99
  // ──────────────────────────────────────────────
171
- export function scrubFile(filePath, opts = {}) {
172
- const { force = false, patterns = PATTERNS, literals = [] } = opts;
173
- if (!force && isAlreadyScrubbed(filePath)) return "skipped";
100
+ function scrubFile(filePath, force = false) {
101
+ if (!force && isAlreadyScrubbed(filePath)) {
102
+ return "skipped";
103
+ }
174
104
 
175
105
  let content = fs.readFileSync(filePath, "utf-8");
176
106
  let total = 0;
177
107
 
178
- for (const [pattern, replacement] of patterns) {
108
+ for (const [pattern, replacement] of PATTERNS) {
109
+ // Reset lastIndex for global regexes
179
110
  pattern.lastIndex = 0;
180
111
  const matches = content.match(pattern);
181
112
  if (matches) {
@@ -184,42 +115,41 @@ export function scrubFile(filePath, opts = {}) {
184
115
  }
185
116
  }
186
117
 
187
- if (literals.length) {
188
- const r = redactLiterals(content, literals);
189
- content = r.content;
190
- total += r.count;
118
+ if (total > 0) {
119
+ fs.writeFileSync(filePath, content, "utf-8");
191
120
  }
192
121
 
193
- if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
122
+ // Stamp as clean (whether creds found or not)
194
123
  stampScrubbed(filePath);
195
124
  return total;
196
125
  }
197
126
 
198
127
  // ──────────────────────────────────────────────
199
- // Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
128
+ // Find all transcript .jsonl files
200
129
  // ──────────────────────────────────────────────
201
- const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
202
-
203
- export function findTranscripts(root) {
204
- const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
130
+ function findTranscripts() {
131
+ const claudeDir = path.join(os.homedir(), ".claude", "projects");
205
132
  if (!fs.existsSync(claudeDir)) return [];
133
+
206
134
  const results = [];
207
135
  function walk(dir) {
208
- let entries;
209
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
210
- for (const entry of entries) {
136
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
211
137
  const full = path.join(dir, entry.name);
212
138
  if (entry.isDirectory()) walk(full);
213
- else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
139
+ else if (entry.name.endsWith(".jsonl")) results.push(full);
214
140
  }
215
141
  }
216
142
  walk(claudeDir);
217
143
  return results;
218
144
  }
219
145
 
146
+ // ──────────────────────────────────────────────
147
+ // Find the most recent transcript (active session)
148
+ // ──────────────────────────────────────────────
220
149
  function findMostRecent() {
221
150
  const files = findTranscripts();
222
151
  if (files.length === 0) return null;
152
+
223
153
  let newest = files[0];
224
154
  let newestMtime = fs.statSync(files[0]).mtimeMs;
225
155
  for (const f of files.slice(1)) {
@@ -229,99 +159,73 @@ function findMostRecent() {
229
159
  return newest;
230
160
  }
231
161
 
232
- // ──────────────────────────────────────────────
233
- // Session scope — the ending session's transcript + its sidecars.
234
- // A SessionEnd hook pipes JSON ({transcript_path, session_id, cwd, ...}) on stdin.
235
- // Sidecars live in the dir named like the transcript minus the .jsonl extension.
236
- // ──────────────────────────────────────────────
237
- export function sessionTargets(hook) {
238
- const files = [];
239
- const tp = hook && (hook.transcript_path || hook.transcriptPath);
240
- if (tp && fs.existsSync(tp)) files.push(tp);
241
- if (tp) {
242
- const sidecarDir = tp.replace(/\.jsonl$/i, "");
243
- if (sidecarDir !== tp && fs.existsSync(sidecarDir)) {
244
- for (const f of findTranscripts(sidecarDir)) files.push(f);
245
- }
246
- }
247
- return [...new Set(files)];
248
- }
249
-
250
- async function readHookStdin() {
251
- try {
252
- if (process.stdin.isTTY) return null;
253
- const chunks = [];
254
- for await (const chunk of process.stdin) chunks.push(chunk);
255
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
256
- return raw ? JSON.parse(raw) : null;
257
- } catch {
258
- return null;
259
- }
260
- }
261
-
262
162
  // ──────────────────────────────────────────────
263
163
  // Exported runner
264
164
  // ──────────────────────────────────────────────
265
165
  export async function runScrub(target, opts = {}) {
266
166
  const force = opts.force || false;
267
167
 
268
- const patterns = [...PATTERNS, ...loadExtraPatterns()];
269
- const literals = await fetchVaultValues();
270
-
168
+ // Determine which files to scrub
271
169
  let files;
272
170
  if (target === "all") {
273
171
  files = findTranscripts();
274
- if (files.length === 0) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
275
- console.log(chalk.cyan(`\n Scrubbing ${files.length} file(s) (.jsonl + sidecars)...`));
276
- } else if (target === "session") {
277
- // Scrub ONLY the ending session (transcript + its sidecars). For SessionEnd hooks.
278
- const hook = await readHookStdin();
279
- files = sessionTargets(hook);
280
172
  if (files.length === 0) {
281
- const recent = findMostRecent();
282
- if (!recent) { console.log(chalk.yellow("\n No session transcript found.\n")); return; }
283
- files = [recent];
173
+ console.log(chalk.yellow("\n No transcript files found.\n"));
174
+ return;
284
175
  }
285
- console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
176
+ console.log(chalk.cyan(`\n Scrubbing ${files.length} transcript file(s)...\n`));
286
177
  } else if (target && target !== "all") {
178
+ // Specific file
287
179
  const resolved = path.resolve(target);
288
- if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
180
+ if (!fs.existsSync(resolved)) {
181
+ console.log(chalk.red(`\n File not found: ${resolved}\n`));
182
+ process.exit(1);
183
+ }
289
184
  files = [resolved];
290
185
  } else {
186
+ // No target — find most recent
291
187
  const recent = findMostRecent();
292
- if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
188
+ if (!recent) {
189
+ console.log(chalk.yellow("\n No transcript files found.\n"));
190
+ return;
191
+ }
293
192
  files = [recent];
294
193
  console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
295
194
  }
296
195
 
297
- const layers = [`${patterns.length} pattern(s)`];
298
- if (literals.length) layers.push(chalk.green(`${literals.length} live vault value(s)`));
299
- else layers.push(chalk.gray("vault values: daemon unreachable/locked — regex only"));
300
- console.log(chalk.gray(` Redaction: ${layers.join(", ")}\n`));
301
-
196
+ // Scrub
302
197
  const spinner = ora("Scrubbing credentials...").start();
303
- let grandTotal = 0, skipped = 0, scanned = 0;
198
+ let grandTotal = 0;
199
+ let skipped = 0;
200
+ let scanned = 0;
201
+
304
202
  for (const f of files) {
305
- const result = scrubFile(f, { force, patterns, literals });
306
- if (result === "skipped") { skipped++; continue; }
307
- scanned++;
308
- if (result > 0) {
309
- spinner.stop();
310
- console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
311
- spinner.start("Scrubbing credentials...");
312
- grandTotal += result;
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
+ }
313
214
  }
314
215
  }
216
+
315
217
  spinner.stop();
316
218
 
219
+ // Summary
317
220
  const parts = [];
318
221
  if (scanned) parts.push(`scanned ${scanned}`);
319
222
  if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
320
223
  if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
321
224
  else if (scanned) parts.push(chalk.green("no credentials found"));
225
+
322
226
  console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
323
- if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
227
+ if (skipped && !force) {
228
+ console.log(chalk.gray(" (use --force to rescan all files)"));
229
+ }
324
230
  console.log();
325
231
  }
326
-
327
- export { PATTERNS, SCRUB_VERSION };