@lifeaitools/clauth 1.30.13 → 1.30.14
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 +75 -17
- package/README.md +70 -10
- package/cli/api.classify.test.js +75 -0
- package/cli/api.js +110 -11
- package/cli/commands/agent-cron.js +396 -0
- package/cli/commands/agent-pool.js +1962 -0
- package/cli/commands/scrub.js +205 -109
- package/cli/commands/scrub.test.js +115 -0
- package/cli/commands/serve.js +3488 -1068
- package/cli/enrollment-script.js +82 -0
- package/cli/index.js +23 -57
- package/cli/studio-debug.js +679 -8
- package/cli/webdav-service.js +339 -0
- package/package.json +11 -3
- package/scripts/postinstall.js +25 -0
package/cli/commands/scrub.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
|
|
1
|
+
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
|
|
2
2
|
//
|
|
3
|
-
// clauth scrub → scrub active transcript (most recent
|
|
3
|
+
// clauth scrub → scrub active transcript (most recent)
|
|
4
4
|
// clauth scrub <file> → scrub a specific file
|
|
5
|
-
// clauth scrub all → scrub every transcript
|
|
5
|
+
// clauth scrub all → scrub every transcript + tool-result sidecar
|
|
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).
|
|
7
15
|
|
|
8
16
|
import fs from "fs";
|
|
9
17
|
import path from "path";
|
|
@@ -12,53 +20,125 @@ import chalk from "chalk";
|
|
|
12
20
|
import ora from "ora";
|
|
13
21
|
|
|
14
22
|
const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
|
|
15
|
-
const SCRUB_VERSION = "1.
|
|
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;
|
|
16
34
|
|
|
17
35
|
// ──────────────────────────────────────────────
|
|
18
|
-
//
|
|
36
|
+
// Built-in credential patterns — regex + replacement
|
|
19
37
|
// ──────────────────────────────────────────────
|
|
20
38
|
const PATTERNS = [
|
|
21
|
-
|
|
22
|
-
[/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
[/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
[/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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]"],
|
|
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]"],
|
|
58
56
|
];
|
|
59
57
|
|
|
60
58
|
// ──────────────────────────────────────────────
|
|
61
|
-
//
|
|
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
|
|
62
142
|
// ──────────────────────────────────────────────
|
|
63
143
|
function isAlreadyScrubbed(filePath) {
|
|
64
144
|
try {
|
|
@@ -68,28 +148,19 @@ function isAlreadyScrubbed(filePath) {
|
|
|
68
148
|
const buf = Buffer.alloc(bufSize);
|
|
69
149
|
fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
|
|
70
150
|
fs.closeSync(fd);
|
|
71
|
-
|
|
72
|
-
const tail = buf.toString("utf-8");
|
|
73
|
-
const lastLine = tail.trim().split("\n").pop();
|
|
151
|
+
const lastLine = buf.toString("utf-8").trim().split("\n").pop();
|
|
74
152
|
if (lastLine && lastLine.includes(SCRUB_MARKER)) {
|
|
75
|
-
try {
|
|
76
|
-
const marker = JSON.parse(lastLine);
|
|
77
|
-
return marker.version === SCRUB_VERSION;
|
|
78
|
-
} catch { return false; }
|
|
153
|
+
try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
|
|
79
154
|
}
|
|
80
155
|
return false;
|
|
81
156
|
} catch { return false; }
|
|
82
157
|
}
|
|
83
158
|
|
|
84
|
-
// ──────────────────────────────────────────────
|
|
85
|
-
// Stamp file as scrubbed
|
|
86
|
-
// ──────────────────────────────────────────────
|
|
87
159
|
function stampScrubbed(filePath) {
|
|
88
160
|
const marker = JSON.stringify({
|
|
89
161
|
type: SCRUB_MARKER,
|
|
90
162
|
version: SCRUB_VERSION,
|
|
91
163
|
scrubbed_at: new Date().toISOString(),
|
|
92
|
-
patterns: PATTERNS.length,
|
|
93
164
|
});
|
|
94
165
|
fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
|
|
95
166
|
}
|
|
@@ -97,16 +168,14 @@ function stampScrubbed(filePath) {
|
|
|
97
168
|
// ──────────────────────────────────────────────
|
|
98
169
|
// Scrub a single file
|
|
99
170
|
// ──────────────────────────────────────────────
|
|
100
|
-
function scrubFile(filePath,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
171
|
+
export function scrubFile(filePath, opts = {}) {
|
|
172
|
+
const { force = false, patterns = PATTERNS, literals = [] } = opts;
|
|
173
|
+
if (!force && isAlreadyScrubbed(filePath)) return "skipped";
|
|
104
174
|
|
|
105
175
|
let content = fs.readFileSync(filePath, "utf-8");
|
|
106
176
|
let total = 0;
|
|
107
177
|
|
|
108
|
-
for (const [pattern, replacement] of
|
|
109
|
-
// Reset lastIndex for global regexes
|
|
178
|
+
for (const [pattern, replacement] of patterns) {
|
|
110
179
|
pattern.lastIndex = 0;
|
|
111
180
|
const matches = content.match(pattern);
|
|
112
181
|
if (matches) {
|
|
@@ -115,41 +184,42 @@ function scrubFile(filePath, force = false) {
|
|
|
115
184
|
}
|
|
116
185
|
}
|
|
117
186
|
|
|
118
|
-
if (
|
|
119
|
-
|
|
187
|
+
if (literals.length) {
|
|
188
|
+
const r = redactLiterals(content, literals);
|
|
189
|
+
content = r.content;
|
|
190
|
+
total += r.count;
|
|
120
191
|
}
|
|
121
192
|
|
|
122
|
-
|
|
193
|
+
if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
|
|
123
194
|
stampScrubbed(filePath);
|
|
124
195
|
return total;
|
|
125
196
|
}
|
|
126
197
|
|
|
127
198
|
// ──────────────────────────────────────────────
|
|
128
|
-
// Find all
|
|
199
|
+
// Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
|
|
129
200
|
// ──────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
const claudeDir = path.join(os.homedir(), ".claude", "projects");
|
|
132
|
-
if (!fs.existsSync(claudeDir)) return [];
|
|
201
|
+
const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
|
|
133
202
|
|
|
203
|
+
export function findTranscripts(root) {
|
|
204
|
+
const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
|
|
205
|
+
if (!fs.existsSync(claudeDir)) return [];
|
|
134
206
|
const results = [];
|
|
135
207
|
function walk(dir) {
|
|
136
|
-
|
|
208
|
+
let entries;
|
|
209
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
210
|
+
for (const entry of entries) {
|
|
137
211
|
const full = path.join(dir, entry.name);
|
|
138
212
|
if (entry.isDirectory()) walk(full);
|
|
139
|
-
else if (
|
|
213
|
+
else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
|
|
140
214
|
}
|
|
141
215
|
}
|
|
142
216
|
walk(claudeDir);
|
|
143
217
|
return results;
|
|
144
218
|
}
|
|
145
219
|
|
|
146
|
-
// ──────────────────────────────────────────────
|
|
147
|
-
// Find the most recent transcript (active session)
|
|
148
|
-
// ──────────────────────────────────────────────
|
|
149
220
|
function findMostRecent() {
|
|
150
221
|
const files = findTranscripts();
|
|
151
222
|
if (files.length === 0) return null;
|
|
152
|
-
|
|
153
223
|
let newest = files[0];
|
|
154
224
|
let newestMtime = fs.statSync(files[0]).mtimeMs;
|
|
155
225
|
for (const f of files.slice(1)) {
|
|
@@ -159,73 +229,99 @@ function findMostRecent() {
|
|
|
159
229
|
return newest;
|
|
160
230
|
}
|
|
161
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
|
+
|
|
162
262
|
// ──────────────────────────────────────────────
|
|
163
263
|
// Exported runner
|
|
164
264
|
// ──────────────────────────────────────────────
|
|
165
265
|
export async function runScrub(target, opts = {}) {
|
|
166
266
|
const force = opts.force || false;
|
|
167
267
|
|
|
168
|
-
|
|
268
|
+
const patterns = [...PATTERNS, ...loadExtraPatterns()];
|
|
269
|
+
const literals = await fetchVaultValues();
|
|
270
|
+
|
|
169
271
|
let files;
|
|
170
272
|
if (target === "all") {
|
|
171
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);
|
|
172
280
|
if (files.length === 0) {
|
|
173
|
-
|
|
174
|
-
return;
|
|
281
|
+
const recent = findMostRecent();
|
|
282
|
+
if (!recent) { console.log(chalk.yellow("\n No session transcript found.\n")); return; }
|
|
283
|
+
files = [recent];
|
|
175
284
|
}
|
|
176
|
-
console.log(chalk.
|
|
285
|
+
console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
|
|
177
286
|
} else if (target && target !== "all") {
|
|
178
|
-
// Specific file
|
|
179
287
|
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
|
-
}
|
|
288
|
+
if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
|
|
184
289
|
files = [resolved];
|
|
185
290
|
} else {
|
|
186
|
-
// No target — find most recent
|
|
187
291
|
const recent = findMostRecent();
|
|
188
|
-
if (!recent) {
|
|
189
|
-
console.log(chalk.yellow("\n No transcript files found.\n"));
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
292
|
+
if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
|
|
192
293
|
files = [recent];
|
|
193
294
|
console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
|
|
194
295
|
}
|
|
195
296
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
let scanned = 0;
|
|
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`));
|
|
201
301
|
|
|
302
|
+
const spinner = ora("Scrubbing credentials...").start();
|
|
303
|
+
let grandTotal = 0, skipped = 0, scanned = 0;
|
|
202
304
|
for (const f of files) {
|
|
203
|
-
const result = scrubFile(f, force);
|
|
204
|
-
if (result === "skipped") {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
spinner.start("Scrubbing credentials...");
|
|
212
|
-
grandTotal += result;
|
|
213
|
-
}
|
|
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;
|
|
214
313
|
}
|
|
215
314
|
}
|
|
216
|
-
|
|
217
315
|
spinner.stop();
|
|
218
316
|
|
|
219
|
-
// Summary
|
|
220
317
|
const parts = [];
|
|
221
318
|
if (scanned) parts.push(`scanned ${scanned}`);
|
|
222
319
|
if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
|
|
223
320
|
if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
|
|
224
321
|
else if (scanned) parts.push(chalk.green("no credentials found"));
|
|
225
|
-
|
|
226
322
|
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
|
-
}
|
|
323
|
+
if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
|
|
230
324
|
console.log();
|
|
231
325
|
}
|
|
326
|
+
|
|
327
|
+
export { PATTERNS, SCRUB_VERSION };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// node --test cli/commands/scrub.test.js
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PATTERNS,
|
|
10
|
+
findTranscripts,
|
|
11
|
+
scrubFile,
|
|
12
|
+
isSecretLike,
|
|
13
|
+
loadExtraPatterns,
|
|
14
|
+
sessionTargets,
|
|
15
|
+
} from "./scrub.js";
|
|
16
|
+
|
|
17
|
+
const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
|
|
18
|
+
const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ"; // secret-like literal
|
|
19
|
+
const CUSTOM_SECRET = "MYCORP-TOKEN-998877"; // only an editable pattern catches it
|
|
20
|
+
|
|
21
|
+
function seedProjects() {
|
|
22
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
|
|
23
|
+
const sess = path.join(root, "proj", "session-uuid");
|
|
24
|
+
const sidecarDir = path.join(sess, "tool-results");
|
|
25
|
+
fs.mkdirSync(sidecarDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const jsonl = path.join(root, "proj", "session-uuid.jsonl");
|
|
28
|
+
const txt = path.join(sidecarDir, "toolu_abc.txt");
|
|
29
|
+
const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
|
|
30
|
+
fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
|
|
31
|
+
fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
|
|
32
|
+
return { root, jsonl, txt };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
|
|
36
|
+
const { root, jsonl, txt } = seedProjects();
|
|
37
|
+
const found = findTranscripts(root);
|
|
38
|
+
assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
|
|
39
|
+
assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
|
|
43
|
+
const { jsonl, txt } = seedProjects();
|
|
44
|
+
for (const f of [jsonl, txt]) {
|
|
45
|
+
const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
|
|
46
|
+
assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
|
|
47
|
+
const after = fs.readFileSync(f, "utf-8");
|
|
48
|
+
assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
|
|
49
|
+
assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
|
|
50
|
+
assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
|
|
55
|
+
const { txt } = seedProjects();
|
|
56
|
+
const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
|
|
57
|
+
assert.equal(n, 1, "exactly one literal occurrence redacted");
|
|
58
|
+
const after = fs.readFileSync(txt, "utf-8");
|
|
59
|
+
assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
|
|
60
|
+
assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("editable patterns file catches a custom secret with no clauth release", () => {
|
|
64
|
+
const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
|
|
65
|
+
fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
|
|
66
|
+
const extra = loadExtraPatterns(cfg);
|
|
67
|
+
assert.equal(extra.length, 1, "one extra pattern loaded");
|
|
68
|
+
|
|
69
|
+
const { txt } = seedProjects();
|
|
70
|
+
const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
|
|
71
|
+
assert.ok(n >= 1, "custom pattern matched");
|
|
72
|
+
const after = fs.readFileSync(txt, "utf-8");
|
|
73
|
+
assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
|
|
74
|
+
assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
|
|
75
|
+
fs.rmSync(cfg, { force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
|
|
79
|
+
assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
|
|
80
|
+
assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
|
|
81
|
+
assert.ok(!isSecretLike("short"), "short value rejected");
|
|
82
|
+
assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
|
|
83
|
+
assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
|
|
84
|
+
assert.ok(!isSecretLike(""), "empty rejected");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
|
|
88
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
|
|
89
|
+
const proj = path.join(root, "C--proj");
|
|
90
|
+
const sid = "11112222-3333-4444-5555-666677778888";
|
|
91
|
+
fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
|
|
92
|
+
const transcript = path.join(proj, `${sid}.jsonl`);
|
|
93
|
+
const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
|
|
94
|
+
const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
|
|
95
|
+
fs.writeFileSync(transcript, "{}\n");
|
|
96
|
+
fs.writeFileSync(sidecar, "tool output\n");
|
|
97
|
+
fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included
|
|
98
|
+
|
|
99
|
+
const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
|
|
100
|
+
assert.ok(targets.includes(transcript), "includes the session transcript");
|
|
101
|
+
assert.ok(targets.includes(sidecar), "includes the session's sidecar");
|
|
102
|
+
assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
|
|
103
|
+
assert.equal(targets.length, 2, "exactly the 2 session files");
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
|
|
106
|
+
assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("loadExtraPatterns tolerates a missing/malformed file", () => {
|
|
110
|
+
assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
|
|
111
|
+
const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
|
|
112
|
+
fs.writeFileSync(bad, "{ not json", "utf-8");
|
|
113
|
+
assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
|
|
114
|
+
fs.rmSync(bad, { force: true });
|
|
115
|
+
});
|