@redaktyn/cli 0.0.0-stage → 1.1.0

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.
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Redaktyn CLI — scan git staged files / paths for enrolled secret digests (ZK: sends HMAC hex only).
4
+ *
5
+ * Requires:
6
+ * REDAKTYN_SERVER_URL — API base URL
7
+ * REDAKTYN_PASSPHRASE — org passphrase (local HMAC only; never sent)
8
+ * REDAKTYN_DEVICE_TOKEN or REDAKTYN_TOKEN — extension JWT or admin JWT (Bearer)
9
+ *
10
+ * Optional:
11
+ * REDAKTYN_ORG_ID — must match JWT orgId when set (sanity check)
12
+ */
13
+
14
+ import { execSync } from "node:child_process";
15
+ import { createRequire } from "node:module";
16
+ import {
17
+ readFileSync,
18
+ existsSync,
19
+ readdirSync,
20
+ statSync,
21
+ writeFileSync,
22
+ chmodSync,
23
+ } from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ /** Resolve `@redaktyn/shared` from monorepo root or local install. */
30
+ function loadShared() {
31
+ try {
32
+ return require("@redaktyn/shared");
33
+ } catch {
34
+ console.error(
35
+ "Cannot load @redaktyn/shared. Run `npm install` from the repo root, then `npm run build -w @redaktyn/shared`."
36
+ );
37
+ process.exit(2);
38
+ }
39
+ }
40
+
41
+ const {
42
+ deriveHmacKeyFromPassphrase,
43
+ hmacDigestHex,
44
+ normalizeSecretPlaintextForMatch,
45
+ normalizeSecretPlaintextCollapsed,
46
+ normalizeSecretPlaintextLower,
47
+ extractAddedLinesFromUnifiedDiff,
48
+ } = loadShared();
49
+
50
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
51
+ const MAX_SCAN_CHARS = 512 * 1024;
52
+
53
+ /**
54
+ * Digests per request. The server accepts 50,000 and rate-limits by principal,
55
+ * so this leaves headroom while keeping the number of round trips low.
56
+ */
57
+ const DIGESTS_PER_REQUEST = 20000;
58
+
59
+ /**
60
+ * Ceiling on total digests for one scan, as a guard against a pathological
61
+ * input rather than a budget anyone should hit. Passing it is reported on
62
+ * stderr and sets a non-zero exit, because a hook that ran out of budget and
63
+ * said "clean" is worse than one that says it could not finish.
64
+ */
65
+ const MAX_TOTAL_DIGESTS = 3_000_000;
66
+
67
+ /**
68
+ * The three passes the extension runs, in the same order and with the same
69
+ * normalisation.
70
+ *
71
+ * This file used to run the first one only, which made the hook weaker than the
72
+ * browser it stands in for: a secret committed with inserted spaces or altered
73
+ * case passed `--staged` and was then blocked on paste. Enrolment stores all
74
+ * three digests and the server checks every submitted digest against all three
75
+ * columns, so the gap was only ever here.
76
+ *
77
+ * Each pass normalises the whole text and then slides raw windows over it,
78
+ * which is what the extension does. Normalising each window instead — the
79
+ * previous behaviour — trims its ends, so a window could never align with a
80
+ * secret when the surrounding text carried CRLF or zero-width characters.
81
+ */
82
+ const PASSES = [
83
+ { name: "exact", transform: normalizeSecretPlaintextForMatch, lengthKey: "length" },
84
+ { name: "whitespace-collapse", transform: normalizeSecretPlaintextCollapsed, lengthKey: "lengthStripped" },
85
+ { name: "case-fold", transform: normalizeSecretPlaintextLower, lengthKey: "lengthLower" },
86
+ ];
87
+
88
+ const BINARY_EXT = new Set([
89
+ ".png",
90
+ ".jpg",
91
+ ".jpeg",
92
+ ".gif",
93
+ ".webp",
94
+ ".ico",
95
+ ".pdf",
96
+ ".zip",
97
+ ".gz",
98
+ ".exe",
99
+ ".dll",
100
+ ".so",
101
+ ".dylib",
102
+ ".woff",
103
+ ".woff2",
104
+ ".ttf",
105
+ ".eot",
106
+ ".mp4",
107
+ ".mp3",
108
+ ".wasm",
109
+ ".bin",
110
+ ]);
111
+
112
+ function parseArgs(argv) {
113
+ const opts = { exclude: [], _: [] };
114
+ for (let i = 2; i < argv.length; i++) {
115
+ const a = argv[i];
116
+ if (a === "--staged") opts.staged = true;
117
+ else if (a === "--json") opts.json = true;
118
+ else if (a === "--no-color") opts.noColor = true;
119
+ else if (a === "--verbose") opts.verbose = true;
120
+ else if (a === "--file") opts.file = argv[++i];
121
+ else if (a === "--dir") opts.dir = argv[++i];
122
+ else if (a === "--org-id") opts.orgId = argv[++i];
123
+ else if (a === "--server") opts.server = argv[++i];
124
+ else if (a === "--exclude") opts.exclude.push(argv[++i]);
125
+ else if (a.startsWith("-")) {
126
+ console.error(`Unknown flag: ${a}`);
127
+ process.exit(1);
128
+ } else opts._.push(a);
129
+ }
130
+ const cmd = opts._[0];
131
+ return { ...opts, cmd };
132
+ }
133
+
134
+ function decodeJwtPayload(token) {
135
+ try {
136
+ const p = token.split(".")[1];
137
+ if (!p) return null;
138
+ const padded = p + "=".repeat((4 - (p.length % 4)) % 4);
139
+ const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
140
+ return JSON.parse(json);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function shouldSkipPath(rel, excludeGlobs) {
147
+ const n = rel.replace(/\\/g, "/");
148
+ if (n.includes("node_modules/") || n.startsWith(".git/")) return true;
149
+ for (const g of excludeGlobs) {
150
+ if (g.includes("*")) {
151
+ const re = new RegExp("^" + g.replace(/\*/g, ".*").replace(/\//g, "\\/") + "$");
152
+ if (re.test(n)) return true;
153
+ } else if (n.includes(g)) return true;
154
+ }
155
+ return false;
156
+ }
157
+
158
+ function readTextFileSafe(fp, verbose) {
159
+ const st = statSync(fp);
160
+ if (!st.isFile()) return null;
161
+ if (st.size > MAX_FILE_BYTES) {
162
+ if (verbose) console.warn(`Skipping large file (${st.size} bytes): ${fp}`);
163
+ return null;
164
+ }
165
+ const ext = path.extname(fp).toLowerCase();
166
+ if (BINARY_EXT.has(ext)) return null;
167
+ try {
168
+ const buf = readFileSync(fp);
169
+ if (buf.includes(0)) return null;
170
+ return buf.toString("utf8");
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function collectFilesRecursive(dir, exclude, out = []) {
177
+ for (const name of readdirSync(dir)) {
178
+ const fp = path.join(dir, name);
179
+ const rel = path.relative(process.cwd(), fp);
180
+ if (shouldSkipPath(rel, exclude)) continue;
181
+ const st = statSync(fp);
182
+ if (st.isDirectory()) collectFilesRecursive(fp, exclude, out);
183
+ else out.push(fp);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Yields every window digest for every pass, in batches ready to POST.
190
+ *
191
+ * A generator rather than one array because the previous version stopped at
192
+ * 8,000 digests and returned what it had — silently, so a large diff was
193
+ * scanned for its first few kilobytes and reported clean for the rest. Batching
194
+ * lets the whole input be checked instead of the beginning of it.
195
+ */
196
+ function* digestBatches(text, secrets, key, stats) {
197
+ const t = text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text;
198
+ if (text.length > MAX_SCAN_CHARS) {
199
+ console.warn(`Content truncated to ${MAX_SCAN_CHARS} characters for scan.`);
200
+ stats.truncatedChars = true;
201
+ }
202
+
203
+ let batch = [];
204
+ for (const pass of PASSES) {
205
+ const lengths = new Set(
206
+ secrets
207
+ .map((s) => Number(s[pass.lengthKey]))
208
+ .filter((n) => Number.isFinite(n) && n > 0)
209
+ );
210
+ if (lengths.size === 0) continue;
211
+
212
+ const passText = pass.transform(t);
213
+ stats.passesRun.push(pass.name);
214
+
215
+ for (const L of lengths) {
216
+ if (L > passText.length) continue;
217
+ for (let i = 0; i + L <= passText.length; i++) {
218
+ batch.push(hmacDigestHex(passText.slice(i, i + L), key));
219
+ stats.total++;
220
+ if (stats.total >= MAX_TOTAL_DIGESTS) {
221
+ stats.exhausted = true;
222
+ if (batch.length > 0) yield batch;
223
+ return;
224
+ }
225
+ if (batch.length >= DIGESTS_PER_REQUEST) {
226
+ yield batch;
227
+ batch = [];
228
+ }
229
+ }
230
+ }
231
+ }
232
+ if (batch.length > 0) yield batch;
233
+ }
234
+
235
+ async function httpPostJson(url, token, body) {
236
+ const res = await fetch(url, {
237
+ method: "POST",
238
+ headers: {
239
+ "Content-Type": "application/json",
240
+ Authorization: `Bearer ${token}`,
241
+ },
242
+ body: JSON.stringify(body),
243
+ });
244
+ const txt = await res.text();
245
+ let data;
246
+ try {
247
+ data = txt ? JSON.parse(txt) : {};
248
+ } catch {
249
+ data = { raw: txt };
250
+ }
251
+ if (!res.ok) {
252
+ const err = new Error(data.error || res.statusText || "HTTP error");
253
+ err.status = res.status;
254
+ err.body = data;
255
+ throw err;
256
+ }
257
+ return data;
258
+ }
259
+
260
+ async function fetchSecretsMeta(serverUrl, token) {
261
+ const url = `${serverUrl.replace(/\/+$/, "")}/api/secrets`;
262
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
263
+ if (!res.ok) {
264
+ const body = await res.text().catch(() => "");
265
+ throw new Error(`GET /api/secrets failed: ${res.status} ${body.slice(0, 200)}`);
266
+ }
267
+ const text = await res.text();
268
+ try {
269
+ return JSON.parse(text);
270
+ } catch {
271
+ throw new Error(`GET /api/secrets: invalid JSON response`);
272
+ }
273
+ }
274
+
275
+ async function main() {
276
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
277
+ printHelp();
278
+ process.exit(0);
279
+ }
280
+
281
+ const args = parseArgs(process.argv);
282
+ const cmd = args.cmd;
283
+
284
+ if (cmd === "install-hooks") {
285
+ installGitHooks();
286
+ return;
287
+ }
288
+ if (cmd === "uninstall-hooks") {
289
+ uninstallGitHooks();
290
+ return;
291
+ }
292
+ if (cmd === "status") {
293
+ await cmdStatus(args);
294
+ return;
295
+ }
296
+
297
+ if (!args.staged && !args.file && !args.dir) {
298
+ printHelp();
299
+ process.exit(2);
300
+ }
301
+
302
+ const serverUrl =
303
+ args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
304
+ const token =
305
+ process.env["REDAKTYN_DEVICE_TOKEN"] ||
306
+ process.env["REDAKTYN_TOKEN"] ||
307
+ process.env["REDAKTYN_JWT"];
308
+ const passphrase = process.env["REDAKTYN_PASSPHRASE"];
309
+ const envOrgId = args.orgId || process.env["REDAKTYN_ORG_ID"];
310
+
311
+ if (!serverUrl || !token || !passphrase) {
312
+ console.error(
313
+ "Missing env: REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE, and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."
314
+ );
315
+ process.exit(2);
316
+ }
317
+
318
+ const payload = decodeJwtPayload(token);
319
+ const orgId = payload?.orgId ?? payload?.org_id ?? envOrgId;
320
+ if (!orgId) {
321
+ console.error("Could not determine orgId (JWT payload orgId or REDAKTYN_ORG_ID).");
322
+ process.exit(2);
323
+ }
324
+ if (envOrgId && payload?.orgId && envOrgId !== payload.orgId) {
325
+ console.error("REDAKTYN_ORG_ID does not match JWT orgId.");
326
+ process.exit(2);
327
+ }
328
+
329
+ let scanText = "";
330
+ /** Set when the requested target produced no text to scan at all. */
331
+ let unscannedTarget = false;
332
+
333
+ if (args.staged) {
334
+ try {
335
+ const diff = execSync("git diff --cached --unified=0", {
336
+ encoding: "utf8",
337
+ maxBuffer: 20 * 1024 * 1024,
338
+ });
339
+ scanText = extractAddedLinesFromUnifiedDiff(diff);
340
+ } catch {
341
+ console.error("Not a git repository or git diff failed.");
342
+ process.exit(2);
343
+ }
344
+ } else if (args.file) {
345
+ const txt = readTextFileSafe(path.resolve(args.file), args.verbose);
346
+ // `null` is "skipped", not "empty": a directory, a binary extension, a
347
+ // file with a NUL byte, one over the size cap, or one that would not
348
+ // read. An empty file returns "" and genuinely has nothing in it.
349
+ if (txt === null) {
350
+ unscannedTarget = true;
351
+ console.error(`Could not read ${args.file} as text — nothing was scanned.`);
352
+ }
353
+ scanText = txt ?? "";
354
+ } else if (args.dir) {
355
+ const files = collectFilesRecursive(path.resolve(args.dir), args.exclude || []);
356
+ const chunks = [];
357
+ for (const fp of files) {
358
+ const txt = readTextFileSafe(fp, args.verbose);
359
+ if (txt !== null) chunks.push(txt);
360
+ }
361
+ // Individual skips are ordinary — every repository holds images and
362
+ // binaries. Reading nothing at all is not: it means the directory was
363
+ // wrong, entirely excluded, or entirely unreadable.
364
+ if (chunks.length === 0) {
365
+ unscannedTarget = true;
366
+ console.error(`No readable text files under ${args.dir} — nothing was scanned.`);
367
+ }
368
+ scanText = chunks.join("\n");
369
+ } else {
370
+ printHelp();
371
+ process.exit(args.cmd ? 2 : 0);
372
+ }
373
+
374
+ const secrets = await fetchSecretsMeta(serverUrl, token);
375
+ if (!Array.isArray(secrets) || secrets.length === 0) {
376
+ console.error("No enforced secrets returned from server.");
377
+ process.exit(2);
378
+ }
379
+
380
+ const key = await deriveHmacKeyFromPassphrase(passphrase, orgId);
381
+ const stats = { total: 0, passesRun: [], exhausted: false, truncatedChars: false };
382
+ const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/cli/scan`;
383
+
384
+ // Deduplicated across the whole scan, not per batch: the same window recurs
385
+ // constantly in real files, and the three passes overlap heavily on text with
386
+ // no whitespace in it.
387
+ const seen = new Set();
388
+ const byId = new Map();
389
+ for (const batch of digestBatches(scanText, secrets, key, stats)) {
390
+ const fresh = [];
391
+ for (const d of batch) {
392
+ if (seen.has(d)) continue;
393
+ seen.add(d);
394
+ fresh.push(d);
395
+ }
396
+ if (fresh.length === 0) continue;
397
+ const data = await httpPostJson(endpoint, token, { digestHex: fresh });
398
+ for (const m of Array.isArray(data.matches) ? data.matches : []) {
399
+ if (m && m.secretId) byId.set(m.secretId, m);
400
+ }
401
+ }
402
+ const matches = [...byId.values()];
403
+
404
+ // Never reported as clean. An incomplete scan that exits 0 is the failure
405
+ // mode a pre-commit hook is least able to survive, because nobody looks
406
+ // again. A target that was never read belongs in the same bucket and used
407
+ // not to be: pointing `--file` at a binary, a directory, or something over
408
+ // the size cap scanned zero bytes and exited 0, so the hook passed on a
409
+ // file nobody had checked.
410
+ const incomplete = stats.exhausted || stats.truncatedChars || unscannedTarget;
411
+
412
+ if (args.json) {
413
+ console.log(
414
+ JSON.stringify(
415
+ {
416
+ ok: matches.length === 0 && !incomplete,
417
+ matches,
418
+ scan: {
419
+ passes: stats.passesRun,
420
+ digests: stats.total,
421
+ complete: !incomplete,
422
+ },
423
+ },
424
+ null,
425
+ 2
426
+ )
427
+ );
428
+ process.exit(matches.length > 0 || incomplete ? 1 : 0);
429
+ }
430
+ if (matches.length > 0) {
431
+ console.error("");
432
+ console.error(`Redaktyn: ${matches.length} registered fingerprint${matches.length === 1 ? "" : "s"} matched in scanned content:`);
433
+ for (const m of matches) {
434
+ console.error(` • ${m.label} (${m.secretId})`);
435
+ }
436
+ console.error("");
437
+ console.error("Remove the secret material, or rotate it and register the new fingerprint, before committing.");
438
+ process.exit(1);
439
+ }
440
+ if (incomplete) {
441
+ console.error("");
442
+ console.error("Redaktyn could not finish scanning this input, so this is not a clean result.");
443
+ if (stats.truncatedChars) {
444
+ console.error(` • Content was truncated at ${MAX_SCAN_CHARS} characters.`);
445
+ }
446
+ if (stats.exhausted) {
447
+ console.error(` • Hit the ${MAX_TOTAL_DIGESTS} digest ceiling before the end of the input.`);
448
+ }
449
+ if (unscannedTarget) {
450
+ console.error(" • Nothing was read from the path given, so no content was checked.");
451
+ }
452
+ console.error("Scan a narrower path with --file or --dir, or split the commit.");
453
+ process.exit(1);
454
+ }
455
+ if (args.verbose) {
456
+ console.log(
457
+ `Redaktyn: no registered fingerprint matches (${stats.passesRun.join(", ")}; ${stats.total} windows).`
458
+ );
459
+ }
460
+ process.exit(0);
461
+ }
462
+
463
+ async function cmdStatus(args) {
464
+ const serverUrl =
465
+ args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
466
+ const token =
467
+ process.env["REDAKTYN_DEVICE_TOKEN"] ||
468
+ process.env["REDAKTYN_TOKEN"] ||
469
+ process.env["REDAKTYN_JWT"];
470
+ const json = args.json === true;
471
+ if (!serverUrl || !token) {
472
+ const line = { connected: false, reason: "missing REDAKTYN_SERVER_URL or token env" };
473
+ console.log(json ? JSON.stringify(line) : line.reason);
474
+ process.exit(2);
475
+ }
476
+ try {
477
+ const secrets = await fetchSecretsMeta(serverUrl, token);
478
+ const row = {
479
+ connected: true,
480
+ serverUrl,
481
+ enrolledSecrets: Array.isArray(secrets) ? secrets.length : 0,
482
+ hasPassphrase: !!process.env["REDAKTYN_PASSPHRASE"],
483
+ };
484
+ console.log(json ? JSON.stringify(row, null, 2) : Object.entries(row).map(([k, v]) => `${k}: ${v}`).join("\n"));
485
+ process.exit(0);
486
+ } catch (e) {
487
+ const row = { connected: false, error: String(e?.message || e) };
488
+ console.log(json ? JSON.stringify(row) : row.error);
489
+ process.exit(1);
490
+ }
491
+ }
492
+
493
+ function installGitHooks() {
494
+ const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
495
+ const hookPath = path.join(root, ".git", "hooks", "pre-commit");
496
+ const here = fileURLToPath(import.meta.url);
497
+ const body = [
498
+ "#!/bin/sh",
499
+ "# Redaktyn pre-commit — blocks commits when registered fingerprints appear in staged text.",
500
+ 'export REDAKTYN_SERVER_URL="${REDAKTYN_SERVER_URL:-}"',
501
+ 'export REDAKTYN_PASSPHRASE="${REDAKTYN_PASSPHRASE:-}"',
502
+ 'export REDAKTYN_DEVICE_TOKEN="${REDAKTYN_DEVICE_TOKEN:-${REDAKTYN_TOKEN:-}}"',
503
+ 'echo "Redaktyn: scanning staged changes..."',
504
+ "set +e",
505
+ `node "${here}" --staged`,
506
+ "CODE=$?",
507
+ "set -e",
508
+ 'if [ "$CODE" -ne 0 ]; then',
509
+ ' echo "Redaktyn blocked this commit."',
510
+ " exit 1",
511
+ "fi",
512
+ 'echo "Redaktyn: OK"',
513
+ "exit 0",
514
+ "",
515
+ ].join("\n");
516
+ writeFileSync(hookPath, body, { mode: 0o755 });
517
+ chmodSync(hookPath, 0o755);
518
+ console.log("Installed pre-commit hook at", hookPath);
519
+ }
520
+
521
+ function uninstallGitHooks() {
522
+ const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
523
+ const hookPath = path.join(root, ".git", "hooks", "pre-commit");
524
+ if (!existsSync(hookPath)) {
525
+ console.log("No pre-commit hook found.");
526
+ return;
527
+ }
528
+ const txt = readFileSync(hookPath, "utf8");
529
+ if (!txt.includes("Redaktyn")) {
530
+ console.log("pre-commit exists but is not a Redaktyn hook — not removing.");
531
+ return;
532
+ }
533
+ writeFileSync(hookPath, "");
534
+ console.log("Removed Redaktyn pre-commit hook body at", hookPath);
535
+ }
536
+
537
+ function printHelp() {
538
+ console.log(`
539
+ redaktyn-scan — Redaktyn digest scanner (ZK: HMAC hex batches only)
540
+
541
+ Commands:
542
+ (default) --staged | --file <path> | --dir <path>
543
+ status Show connection + registered fingerprint count
544
+ install-hooks Write .git/hooks/pre-commit
545
+ uninstall-hooks Clear Redaktyn pre-commit hook
546
+
547
+ Flags:
548
+ --staged Scan git staged additions (git diff --cached)
549
+ --file <path> Scan a single file
550
+ --dir <path> Scan all text files under directory
551
+ --server <url> Override REDAKTYN_SERVER_URL
552
+ --org-id <uuid> Override org id (must match JWT)
553
+ --json JSON output
554
+ --verbose Extra logs
555
+ --exclude <pat> Skip paths containing pattern (repeatable)
556
+
557
+ Environment:
558
+ REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE, REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)
559
+ `);
560
+ }
561
+
562
+ main().catch((e) => {
563
+ console.error(e?.message || e);
564
+ process.exit(2);
565
+ });