@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.
- package/.clauth-skill/SKILL.md +17 -75
- package/README.md +10 -70
- package/cli/api.js +11 -110
- package/cli/commands/scrub.js +109 -205
- package/cli/commands/serve.js +847 -3056
- package/cli/fingerprint.js +10 -0
- package/cli/index.js +58 -22
- package/cli/studio-debug.js +8 -679
- package/cli/supervisor-registry.js +440 -0
- package/cli/supervisor-registry.test.js +173 -0
- package/package.json +3 -10
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/postinstall.js +0 -25
- package/cli/api.classify.test.js +0 -75
- package/cli/commands/agent-cron.js +0 -396
- package/cli/commands/agent-pool.js +0 -1962
- package/cli/commands/scrub.test.js +0 -115
- package/cli/enrollment-script.js +0 -82
- package/cli/webdav-service.js +0 -339
package/cli/commands/scrub.js
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
18
|
+
// Credential patterns — regex + replacement
|
|
37
19
|
// ──────────────────────────────────────────────
|
|
38
20
|
const PATTERNS = [
|
|
39
|
-
|
|
40
|
-
[/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
[/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
[/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
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 check — read 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
|
-
|
|
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 {
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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 (
|
|
188
|
-
|
|
189
|
-
content = r.content;
|
|
190
|
-
total += r.count;
|
|
118
|
+
if (total > 0) {
|
|
119
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
191
120
|
}
|
|
192
121
|
|
|
193
|
-
|
|
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
|
|
128
|
+
// Find all transcript .jsonl files
|
|
200
129
|
// ──────────────────────────────────────────────
|
|
201
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
files = [recent];
|
|
173
|
+
console.log(chalk.yellow("\n No transcript files found.\n"));
|
|
174
|
+
return;
|
|
284
175
|
}
|
|
285
|
-
console.log(chalk.
|
|
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)) {
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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,
|
|
306
|
-
if (result === "skipped") {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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)
|
|
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 };
|