@lifeaitools/clauth 1.30.23 → 1.30.25
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/.clauth-skill/SKILL.md +306 -275
- package/.clauth-skill/references/operator-guide.md +175 -148
- package/README.md +363 -315
- package/cli/api.classify.test.js +75 -75
- package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
- package/cli/assets/watchdog.ps1 +42 -42
- package/cli/commands/agent-cron.js +396 -396
- package/cli/commands/agent-pool.js +1962 -1962
- package/cli/commands/codevelop.js +1190 -1190
- package/cli/commands/doctor.js +302 -302
- package/cli/commands/install.js +10 -10
- package/cli/commands/invite.js +175 -175
- package/cli/commands/join.js +179 -179
- package/cli/commands/npm.js +182 -182
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/scrub.js +327 -327
- package/cli/commands/scrub.test.js +115 -115
- package/cli/commands/serve.js +381 -98
- package/cli/commands/watchdog.js +209 -209
- package/cli/conf-path.js +21 -21
- package/cli/enrollment-script.js +82 -82
- package/cli/fingerprint.js +143 -143
- package/cli/index.js +1073 -1053
- package/cli/lib/fs-git.js +282 -282
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/recovery.js +101 -101
- package/cli/studio-debug.js +1095 -1095
- package/cli/supervisor-registry.js +594 -589
- package/cli/supervisor-registry.test.js +397 -397
- package/cli/supervisor-ui.test.js +5 -83
- package/cli/watchdog-registry.js +237 -209
- package/cli/watchdog-registry.test.js +112 -89
- package/install.ps1 +21 -21
- package/package.json +4 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/migrations/001_clauth_schema.sql +12 -12
- package/supabase/migrations/003_clauth_config.sql +13 -13
- package/supabase/migrations/003_machine_enrollments.sql +39 -39
- package/cli/served-script-syntax.test.mjs +0 -54
package/cli/commands/scrub.js
CHANGED
|
@@ -1,327 +1,327 @@
|
|
|
1
|
-
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
|
|
2
|
-
//
|
|
3
|
-
// clauth scrub → scrub active transcript (most recent)
|
|
4
|
-
// clauth scrub <file> → scrub a specific file
|
|
5
|
-
// clauth scrub all → scrub every transcript + tool-result sidecar
|
|
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
|
-
|
|
16
|
-
import fs from "fs";
|
|
17
|
-
import path from "path";
|
|
18
|
-
import os from "os";
|
|
19
|
-
import chalk from "chalk";
|
|
20
|
-
import ora from "ora";
|
|
21
|
-
|
|
22
|
-
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;
|
|
34
|
-
|
|
35
|
-
// ──────────────────────────────────────────────
|
|
36
|
-
// Built-in credential patterns — regex + replacement
|
|
37
|
-
// ──────────────────────────────────────────────
|
|
38
|
-
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]"],
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
// ──────────────────────────────────────────────
|
|
59
|
-
// Layer 2 — user-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
|
|
142
|
-
// ──────────────────────────────────────────────
|
|
143
|
-
function isAlreadyScrubbed(filePath) {
|
|
144
|
-
try {
|
|
145
|
-
const stat = fs.statSync(filePath);
|
|
146
|
-
const fd = fs.openSync(filePath, "r");
|
|
147
|
-
const bufSize = Math.min(512, stat.size);
|
|
148
|
-
const buf = Buffer.alloc(bufSize);
|
|
149
|
-
fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
|
|
150
|
-
fs.closeSync(fd);
|
|
151
|
-
const lastLine = buf.toString("utf-8").trim().split("\n").pop();
|
|
152
|
-
if (lastLine && lastLine.includes(SCRUB_MARKER)) {
|
|
153
|
-
try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
|
|
154
|
-
}
|
|
155
|
-
return false;
|
|
156
|
-
} catch { return false; }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function stampScrubbed(filePath) {
|
|
160
|
-
const marker = JSON.stringify({
|
|
161
|
-
type: SCRUB_MARKER,
|
|
162
|
-
version: SCRUB_VERSION,
|
|
163
|
-
scrubbed_at: new Date().toISOString(),
|
|
164
|
-
});
|
|
165
|
-
fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ──────────────────────────────────────────────
|
|
169
|
-
// Scrub a single file
|
|
170
|
-
// ──────────────────────────────────────────────
|
|
171
|
-
export function scrubFile(filePath, opts = {}) {
|
|
172
|
-
const { force = false, patterns = PATTERNS, literals = [] } = opts;
|
|
173
|
-
if (!force && isAlreadyScrubbed(filePath)) return "skipped";
|
|
174
|
-
|
|
175
|
-
let content = fs.readFileSync(filePath, "utf-8");
|
|
176
|
-
let total = 0;
|
|
177
|
-
|
|
178
|
-
for (const [pattern, replacement] of patterns) {
|
|
179
|
-
pattern.lastIndex = 0;
|
|
180
|
-
const matches = content.match(pattern);
|
|
181
|
-
if (matches) {
|
|
182
|
-
total += matches.length;
|
|
183
|
-
content = content.replace(pattern, replacement);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (literals.length) {
|
|
188
|
-
const r = redactLiterals(content, literals);
|
|
189
|
-
content = r.content;
|
|
190
|
-
total += r.count;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
|
|
194
|
-
stampScrubbed(filePath);
|
|
195
|
-
return total;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ──────────────────────────────────────────────
|
|
199
|
-
// Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
|
|
200
|
-
// ──────────────────────────────────────────────
|
|
201
|
-
const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
|
|
202
|
-
|
|
203
|
-
export function findTranscripts(root) {
|
|
204
|
-
const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
|
|
205
|
-
if (!fs.existsSync(claudeDir)) return [];
|
|
206
|
-
const results = [];
|
|
207
|
-
function walk(dir) {
|
|
208
|
-
let entries;
|
|
209
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
210
|
-
for (const entry of entries) {
|
|
211
|
-
const full = path.join(dir, entry.name);
|
|
212
|
-
if (entry.isDirectory()) walk(full);
|
|
213
|
-
else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
walk(claudeDir);
|
|
217
|
-
return results;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function findMostRecent() {
|
|
221
|
-
const files = findTranscripts();
|
|
222
|
-
if (files.length === 0) return null;
|
|
223
|
-
let newest = files[0];
|
|
224
|
-
let newestMtime = fs.statSync(files[0]).mtimeMs;
|
|
225
|
-
for (const f of files.slice(1)) {
|
|
226
|
-
const mt = fs.statSync(f).mtimeMs;
|
|
227
|
-
if (mt > newestMtime) { newest = f; newestMtime = mt; }
|
|
228
|
-
}
|
|
229
|
-
return newest;
|
|
230
|
-
}
|
|
231
|
-
|
|
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
|
-
// ──────────────────────────────────────────────
|
|
263
|
-
// Exported runner
|
|
264
|
-
// ──────────────────────────────────────────────
|
|
265
|
-
export async function runScrub(target, opts = {}) {
|
|
266
|
-
const force = opts.force || false;
|
|
267
|
-
|
|
268
|
-
const patterns = [...PATTERNS, ...loadExtraPatterns()];
|
|
269
|
-
const literals = await fetchVaultValues();
|
|
270
|
-
|
|
271
|
-
let files;
|
|
272
|
-
if (target === "all") {
|
|
273
|
-
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
|
-
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];
|
|
284
|
-
}
|
|
285
|
-
console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
|
|
286
|
-
} else if (target && target !== "all") {
|
|
287
|
-
const resolved = path.resolve(target);
|
|
288
|
-
if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
|
|
289
|
-
files = [resolved];
|
|
290
|
-
} else {
|
|
291
|
-
const recent = findMostRecent();
|
|
292
|
-
if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
|
|
293
|
-
files = [recent];
|
|
294
|
-
console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
|
|
295
|
-
}
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
const spinner = ora("Scrubbing credentials...").start();
|
|
303
|
-
let grandTotal = 0, skipped = 0, scanned = 0;
|
|
304
|
-
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;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
spinner.stop();
|
|
316
|
-
|
|
317
|
-
const parts = [];
|
|
318
|
-
if (scanned) parts.push(`scanned ${scanned}`);
|
|
319
|
-
if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
|
|
320
|
-
if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
|
|
321
|
-
else if (scanned) parts.push(chalk.green("no credentials found"));
|
|
322
|
-
console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
|
|
323
|
-
if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
|
|
324
|
-
console.log();
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export { PATTERNS, SCRUB_VERSION };
|
|
1
|
+
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
|
|
2
|
+
//
|
|
3
|
+
// clauth scrub → scrub active transcript (most recent)
|
|
4
|
+
// clauth scrub <file> → scrub a specific file
|
|
5
|
+
// clauth scrub all → scrub every transcript + tool-result sidecar
|
|
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
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import os from "os";
|
|
19
|
+
import chalk from "chalk";
|
|
20
|
+
import ora from "ora";
|
|
21
|
+
|
|
22
|
+
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;
|
|
34
|
+
|
|
35
|
+
// ──────────────────────────────────────────────
|
|
36
|
+
// Built-in credential patterns — regex + replacement
|
|
37
|
+
// ──────────────────────────────────────────────
|
|
38
|
+
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]"],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ──────────────────────────────────────────────
|
|
59
|
+
// Layer 2 — user-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
|
|
142
|
+
// ──────────────────────────────────────────────
|
|
143
|
+
function isAlreadyScrubbed(filePath) {
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.statSync(filePath);
|
|
146
|
+
const fd = fs.openSync(filePath, "r");
|
|
147
|
+
const bufSize = Math.min(512, stat.size);
|
|
148
|
+
const buf = Buffer.alloc(bufSize);
|
|
149
|
+
fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
|
|
150
|
+
fs.closeSync(fd);
|
|
151
|
+
const lastLine = buf.toString("utf-8").trim().split("\n").pop();
|
|
152
|
+
if (lastLine && lastLine.includes(SCRUB_MARKER)) {
|
|
153
|
+
try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
} catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stampScrubbed(filePath) {
|
|
160
|
+
const marker = JSON.stringify({
|
|
161
|
+
type: SCRUB_MARKER,
|
|
162
|
+
version: SCRUB_VERSION,
|
|
163
|
+
scrubbed_at: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ──────────────────────────────────────────────
|
|
169
|
+
// Scrub a single file
|
|
170
|
+
// ──────────────────────────────────────────────
|
|
171
|
+
export function scrubFile(filePath, opts = {}) {
|
|
172
|
+
const { force = false, patterns = PATTERNS, literals = [] } = opts;
|
|
173
|
+
if (!force && isAlreadyScrubbed(filePath)) return "skipped";
|
|
174
|
+
|
|
175
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
176
|
+
let total = 0;
|
|
177
|
+
|
|
178
|
+
for (const [pattern, replacement] of patterns) {
|
|
179
|
+
pattern.lastIndex = 0;
|
|
180
|
+
const matches = content.match(pattern);
|
|
181
|
+
if (matches) {
|
|
182
|
+
total += matches.length;
|
|
183
|
+
content = content.replace(pattern, replacement);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (literals.length) {
|
|
188
|
+
const r = redactLiterals(content, literals);
|
|
189
|
+
content = r.content;
|
|
190
|
+
total += r.count;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
|
|
194
|
+
stampScrubbed(filePath);
|
|
195
|
+
return total;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ──────────────────────────────────────────────
|
|
199
|
+
// Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
|
|
200
|
+
// ──────────────────────────────────────────────
|
|
201
|
+
const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
|
|
202
|
+
|
|
203
|
+
export function findTranscripts(root) {
|
|
204
|
+
const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
|
|
205
|
+
if (!fs.existsSync(claudeDir)) return [];
|
|
206
|
+
const results = [];
|
|
207
|
+
function walk(dir) {
|
|
208
|
+
let entries;
|
|
209
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const full = path.join(dir, entry.name);
|
|
212
|
+
if (entry.isDirectory()) walk(full);
|
|
213
|
+
else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
walk(claudeDir);
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function findMostRecent() {
|
|
221
|
+
const files = findTranscripts();
|
|
222
|
+
if (files.length === 0) return null;
|
|
223
|
+
let newest = files[0];
|
|
224
|
+
let newestMtime = fs.statSync(files[0]).mtimeMs;
|
|
225
|
+
for (const f of files.slice(1)) {
|
|
226
|
+
const mt = fs.statSync(f).mtimeMs;
|
|
227
|
+
if (mt > newestMtime) { newest = f; newestMtime = mt; }
|
|
228
|
+
}
|
|
229
|
+
return newest;
|
|
230
|
+
}
|
|
231
|
+
|
|
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
|
+
// ──────────────────────────────────────────────
|
|
263
|
+
// Exported runner
|
|
264
|
+
// ──────────────────────────────────────────────
|
|
265
|
+
export async function runScrub(target, opts = {}) {
|
|
266
|
+
const force = opts.force || false;
|
|
267
|
+
|
|
268
|
+
const patterns = [...PATTERNS, ...loadExtraPatterns()];
|
|
269
|
+
const literals = await fetchVaultValues();
|
|
270
|
+
|
|
271
|
+
let files;
|
|
272
|
+
if (target === "all") {
|
|
273
|
+
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
|
+
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];
|
|
284
|
+
}
|
|
285
|
+
console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
|
|
286
|
+
} else if (target && target !== "all") {
|
|
287
|
+
const resolved = path.resolve(target);
|
|
288
|
+
if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
|
|
289
|
+
files = [resolved];
|
|
290
|
+
} else {
|
|
291
|
+
const recent = findMostRecent();
|
|
292
|
+
if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
|
|
293
|
+
files = [recent];
|
|
294
|
+
console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
|
|
295
|
+
}
|
|
296
|
+
|
|
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
|
+
|
|
302
|
+
const spinner = ora("Scrubbing credentials...").start();
|
|
303
|
+
let grandTotal = 0, skipped = 0, scanned = 0;
|
|
304
|
+
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;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
spinner.stop();
|
|
316
|
+
|
|
317
|
+
const parts = [];
|
|
318
|
+
if (scanned) parts.push(`scanned ${scanned}`);
|
|
319
|
+
if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
|
|
320
|
+
if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
|
|
321
|
+
else if (scanned) parts.push(chalk.green("no credentials found"));
|
|
322
|
+
console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
|
|
323
|
+
if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
|
|
324
|
+
console.log();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export { PATTERNS, SCRUB_VERSION };
|