@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.
- package/README.md +561 -2
- package/package.json +44 -4
- package/redaktyn-fingerprint.mjs +814 -0
- package/redaktyn-import.mjs +1652 -0
- package/redaktyn-scan.mjs +565 -0
- package/redaktyn-verify.mjs +557 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ┌─────────────────────────────────────────────────────────────────┐
|
|
4
|
+
* │ Redaktyn Import CLI · Bulk enrollment from files and vaults │
|
|
5
|
+
* │ Digests are computed here. Values never leave this machine. │
|
|
6
|
+
* └─────────────────────────────────────────────────────────────────┘
|
|
7
|
+
*
|
|
8
|
+
* Enrollment used to mean typing every secret into the dashboard by hand, so
|
|
9
|
+
* coverage was whatever the admin remembered. The values are already sitting
|
|
10
|
+
* somewhere — a dotenv file, Vault, AWS Secrets Manager, Doppler, 1Password —
|
|
11
|
+
* and every one of those can already print JSON. So this reads that, computes
|
|
12
|
+
* the HMAC digests locally, and sends only digests.
|
|
13
|
+
*
|
|
14
|
+
* Input modes:
|
|
15
|
+
* --env <file> dotenv (supports `export `, quotes, comments, multi-line)
|
|
16
|
+
* --json <file> JSON object of secrets
|
|
17
|
+
* --yaml <file> flat `key: value` YAML only — refuses anything nested
|
|
18
|
+
* --stdin JSON on stdin; this is what makes every vault work today
|
|
19
|
+
*
|
|
20
|
+
* Vault recipes (all of these pipe into --stdin):
|
|
21
|
+
* vault kv get -format=json secret/prod | redaktyn-import --stdin
|
|
22
|
+
* aws secretsmanager get-secret-value --secret-id prod \
|
|
23
|
+
* --query SecretString --output text | redaktyn-import --stdin
|
|
24
|
+
* doppler secrets download --no-file --format json | redaktyn-import --stdin
|
|
25
|
+
* op item get prod --format json | redaktyn-import --stdin
|
|
26
|
+
*
|
|
27
|
+
* Nothing is written without --apply. Without it the command prints the plan
|
|
28
|
+
* and exits, because this tool changes what gets blocked for a whole org.
|
|
29
|
+
*
|
|
30
|
+
* Environment:
|
|
31
|
+
* REDAKTYN_SERVER_URL API base URL
|
|
32
|
+
* REDAKTYN_PASSPHRASE org passphrase — used locally for HMAC, never sent
|
|
33
|
+
* REDAKTYN_TOKEN OWNER/ADMIN JWT (an extension token cannot enroll)
|
|
34
|
+
* REDAKTYN_ORG_ID optional; must agree with the JWT when both are set
|
|
35
|
+
*
|
|
36
|
+
* Requires Node.js >= 18.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
import { basename } from "node:path";
|
|
41
|
+
import * as readline from "node:readline";
|
|
42
|
+
import { pathToFileURL } from "node:url";
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
canonicalize,
|
|
46
|
+
computeHmac,
|
|
47
|
+
deriveKey,
|
|
48
|
+
deriveProjectKey,
|
|
49
|
+
enrollPayload,
|
|
50
|
+
} from "./redaktyn-fingerprint.mjs";
|
|
51
|
+
|
|
52
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
53
|
+
Colors
|
|
54
|
+
═══════════════════════════════════════════════════════════════ */
|
|
55
|
+
const NO_COLOR = process.argv.includes("--no-color") || !!process.env["NO_COLOR"];
|
|
56
|
+
const c = {
|
|
57
|
+
reset: NO_COLOR ? "" : "\x1b[0m",
|
|
58
|
+
bold: NO_COLOR ? "" : "\x1b[1m",
|
|
59
|
+
dim: NO_COLOR ? "" : "\x1b[2m",
|
|
60
|
+
red: NO_COLOR ? "" : "\x1b[31m",
|
|
61
|
+
green: NO_COLOR ? "" : "\x1b[32m",
|
|
62
|
+
yellow: NO_COLOR ? "" : "\x1b[33m",
|
|
63
|
+
blue: NO_COLOR ? "" : "\x1b[34m",
|
|
64
|
+
cyan: NO_COLOR ? "" : "\x1b[36m",
|
|
65
|
+
gray: NO_COLOR ? "" : "\x1b[90m",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Usage and configuration errors: message, exit 2, no stack trace. */
|
|
69
|
+
class UsageError extends Error {}
|
|
70
|
+
|
|
71
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
72
|
+
Scan budget — mirrors extension/src/security-config.ts
|
|
73
|
+
═══════════════════════════════════════════════════════════════ */
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Must equal MAX_SCAN_WINDOWS in extension/src/security-config.ts. A guard test
|
|
77
|
+
* compares the two.
|
|
78
|
+
*
|
|
79
|
+
* This is not a soft performance number. Over this budget the extension returns
|
|
80
|
+
* `too_large` and refuses the scan, which hard-blocks in sensitive contexts —
|
|
81
|
+
* so enrolling enough secrets of enough different lengths does not slow pastes
|
|
82
|
+
* down, it stops them working. An import is the only operation that can cross
|
|
83
|
+
* the line in one step, which is why it reports the ceiling.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_SCAN_WINDOWS = 1_500_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ceiling thresholds, chosen from measurements rather than taste.
|
|
89
|
+
* `bench-scan-latency.bench.test.ts` ("bulk-import-load") records:
|
|
90
|
+
*
|
|
91
|
+
* 100 secrets · 300 window lengths ... ceiling 5,068 chars, 7.7 s at it
|
|
92
|
+
* 200 secrets · 600 window lengths ... ceiling 2,618 chars, 7.4 s at it
|
|
93
|
+
*
|
|
94
|
+
* Those are secrets without a scan prefilter. Everything this command enrols
|
|
95
|
+
* carries one, and fifty such secrets leave a ceiling in the megabytes.
|
|
96
|
+
*
|
|
97
|
+
* A ceiling under 50k means a large file paste is already refused, and under
|
|
98
|
+
* 10k means ordinary ones are. Every org above a few secrets has *some* ceiling,
|
|
99
|
+
* so it is always reported and only escalated when it starts to bite.
|
|
100
|
+
*/
|
|
101
|
+
const PASTE_CEILING_WARN_CHARS = 50_000;
|
|
102
|
+
const PASTE_CEILING_ALARM_CHARS = 10_000;
|
|
103
|
+
|
|
104
|
+
/** Must equal MAX_TOTAL_SCAN_CHARS in extension/src/security-config.ts. */
|
|
105
|
+
const MAX_TOTAL_SCAN_CHARS = 5 * 1024 * 1024;
|
|
106
|
+
|
|
107
|
+
/** Must equal SCAN_PREFILTER_WINDOWS_PER_SIGNATURE in shared/scan-budget.ts. */
|
|
108
|
+
const SCAN_PREFILTER_WINDOWS_PER_SIGNATURE = 256;
|
|
109
|
+
/** Must equal SCAN_PREFILTER_SPACE in shared/scan-prefilter.ts. */
|
|
110
|
+
const SCAN_PREFILTER_SPACE = 65_536;
|
|
111
|
+
const SCAN_PREFILTER_PATTERN = /^v1\.[0-9a-f]{12}$/;
|
|
112
|
+
|
|
113
|
+
const hasScanPrefilter = (s) =>
|
|
114
|
+
typeof s.scanPrefilter === "string" && SCAN_PREFILTER_PATTERN.test(s.scanPrefilter);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every sweep a scan runs: one per distinct window length per pass (exact,
|
|
118
|
+
* whitespace-collapse, case-fold), and whether the scan prefilter applies.
|
|
119
|
+
*
|
|
120
|
+
* Per pass, never de-duplicated across passes: the three lengths of one secret
|
|
121
|
+
* are usually equal, but the passes run over three different texts, so each
|
|
122
|
+
* costs its own sweep. A length is prefiltered only when every secret at it
|
|
123
|
+
* carries a valid prefilter, because the extension sweeps the whole length in
|
|
124
|
+
* full otherwise. Mirrors scanSweeps() in shared/scan-budget.ts.
|
|
125
|
+
*/
|
|
126
|
+
function scanSweeps(secrets, assumeFingerprinted = false) {
|
|
127
|
+
const passes = [new Map(), new Map(), new Map()];
|
|
128
|
+
const add = (pass, windowLen, prefiltered) => {
|
|
129
|
+
if (!(windowLen > 0)) return;
|
|
130
|
+
const seen = passes[pass].get(windowLen);
|
|
131
|
+
passes[pass].set(
|
|
132
|
+
windowLen,
|
|
133
|
+
seen
|
|
134
|
+
? { windowLen, prefiltered: seen.prefiltered && prefiltered, entries: seen.entries + 1 }
|
|
135
|
+
: { windowLen, prefiltered, entries: 1 }
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
for (const s of secrets) {
|
|
139
|
+
const prefiltered = assumeFingerprinted || hasScanPrefilter(s);
|
|
140
|
+
if (s.hmacHex) add(0, s.length, prefiltered);
|
|
141
|
+
if (typeof s.hmacHexStripped === "string") add(1, s.lengthStripped, prefiltered);
|
|
142
|
+
if (typeof s.hmacHexLower === "string") add(2, s.lengthLower, prefiltered);
|
|
143
|
+
}
|
|
144
|
+
return passes.flatMap((p) => [...p.values()]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the extension's admission check charges for a text of `textChars`
|
|
149
|
+
* characters, with no discount for the text's shape. Mirrors
|
|
150
|
+
* projectedScanCost() in shared/scan-budget.ts.
|
|
151
|
+
*/
|
|
152
|
+
function projectedScanCost(textChars, sweeps) {
|
|
153
|
+
let cost = 0;
|
|
154
|
+
for (const sweep of sweeps) {
|
|
155
|
+
const examined = Math.max(0, textChars - sweep.windowLen + 1);
|
|
156
|
+
cost += sweep.prefiltered
|
|
157
|
+
? Math.ceil(examined / SCAN_PREFILTER_WINDOWS_PER_SIGNATURE) +
|
|
158
|
+
Math.ceil((examined * sweep.entries) / SCAN_PREFILTER_SPACE)
|
|
159
|
+
: examined;
|
|
160
|
+
}
|
|
161
|
+
return cost;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Largest text the extension is guaranteed to scan, capped at its hard limit. */
|
|
165
|
+
function maxScannablePasteCharsFor(sweeps) {
|
|
166
|
+
if (sweeps.length === 0) return Infinity;
|
|
167
|
+
if (projectedScanCost(MAX_TOTAL_SCAN_CHARS, sweeps) <= MAX_SCAN_WINDOWS) return MAX_TOTAL_SCAN_CHARS;
|
|
168
|
+
let lo = 0;
|
|
169
|
+
let hi = MAX_TOTAL_SCAN_CHARS;
|
|
170
|
+
while (lo < hi) {
|
|
171
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
172
|
+
if (projectedScanCost(mid, sweeps) <= MAX_SCAN_WINDOWS) lo = mid;
|
|
173
|
+
else hi = mid - 1;
|
|
174
|
+
}
|
|
175
|
+
return lo;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The paste ceiling a set of secrets leaves, and what re-enrolling the ones
|
|
180
|
+
* without a scan prefilter would buy back.
|
|
181
|
+
*/
|
|
182
|
+
export function scanBudgetForSecrets(secrets) {
|
|
183
|
+
const sweeps = scanSweeps(secrets);
|
|
184
|
+
const ceiling = maxScannablePasteCharsFor(sweeps);
|
|
185
|
+
return {
|
|
186
|
+
windowLengths: sweeps.length,
|
|
187
|
+
ceiling,
|
|
188
|
+
legacySecrets: secrets.filter((s) => s.hmacHex && !hasScanPrefilter(s)).length,
|
|
189
|
+
ceilingIfReenrolled: maxScannablePasteCharsFor(scanSweeps(secrets, true)),
|
|
190
|
+
atCharacterCap: ceiling === MAX_TOTAL_SCAN_CHARS,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether the server stores scan prefilters. A server that does returns the
|
|
196
|
+
* field on every row, null when absent; one deployed before it omits the key.
|
|
197
|
+
* An empty vault cannot say, so it is assumed current and the write path falls
|
|
198
|
+
* back if the field is refused.
|
|
199
|
+
*/
|
|
200
|
+
function serverStoresScanPrefilter(existing) {
|
|
201
|
+
return (
|
|
202
|
+
existing.length === 0 ||
|
|
203
|
+
existing.some((s) => s && Object.prototype.hasOwnProperty.call(s, "scanPrefilter"))
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A 400 refusing `scanPrefilter` and nothing else: an older server. */
|
|
208
|
+
function refusesOnlyScanPrefilter(r) {
|
|
209
|
+
const fields = r.data && Array.isArray(r.data.unknownFields) ? r.data.unknownFields : null;
|
|
210
|
+
return (
|
|
211
|
+
r.status === 400 &&
|
|
212
|
+
r.data?.code === "UNKNOWN_FIELD" &&
|
|
213
|
+
fields !== null &&
|
|
214
|
+
fields.length === 1 &&
|
|
215
|
+
fields[0] === "scanPrefilter"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
220
|
+
Argument parsing
|
|
221
|
+
═══════════════════════════════════════════════════════════════ */
|
|
222
|
+
function parseArgs(argv) {
|
|
223
|
+
const out = {
|
|
224
|
+
help: false,
|
|
225
|
+
env: null,
|
|
226
|
+
jsonFile: null,
|
|
227
|
+
yaml: null,
|
|
228
|
+
stdin: false,
|
|
229
|
+
source: null,
|
|
230
|
+
apply: false,
|
|
231
|
+
yes: false,
|
|
232
|
+
minLength: null,
|
|
233
|
+
allowWeak: false,
|
|
234
|
+
only: [],
|
|
235
|
+
exclude: [],
|
|
236
|
+
server: null,
|
|
237
|
+
orgId: null,
|
|
238
|
+
project: null,
|
|
239
|
+
reportJson: false,
|
|
240
|
+
noColor: false,
|
|
241
|
+
};
|
|
242
|
+
const need = (i, flag) => {
|
|
243
|
+
const v = argv[i];
|
|
244
|
+
if (v === undefined || v.startsWith("--")) {
|
|
245
|
+
throw new UsageError(`${flag} needs a value`);
|
|
246
|
+
}
|
|
247
|
+
return v;
|
|
248
|
+
};
|
|
249
|
+
for (let i = 2; i < argv.length; i++) {
|
|
250
|
+
const a = argv[i];
|
|
251
|
+
if (a === "--help" || a === "-h") out.help = true;
|
|
252
|
+
else if (a === "--env") out.env = need(++i, "--env");
|
|
253
|
+
else if (a === "--json") out.jsonFile = need(++i, "--json");
|
|
254
|
+
else if (a === "--yaml") out.yaml = need(++i, "--yaml");
|
|
255
|
+
else if (a === "--stdin") out.stdin = true;
|
|
256
|
+
else if (a === "--source") out.source = need(++i, "--source");
|
|
257
|
+
else if (a === "--apply") out.apply = true;
|
|
258
|
+
else if (a === "--yes" || a === "-y") out.yes = true;
|
|
259
|
+
else if (a === "--min-length") out.minLength = Number(need(++i, "--min-length"));
|
|
260
|
+
else if (a === "--allow-weak") out.allowWeak = true;
|
|
261
|
+
else if (a === "--only") out.only.push(need(++i, "--only"));
|
|
262
|
+
else if (a === "--exclude") out.exclude.push(need(++i, "--exclude"));
|
|
263
|
+
else if (a === "--server") out.server = need(++i, "--server");
|
|
264
|
+
else if (a === "--org-id") out.orgId = need(++i, "--org-id");
|
|
265
|
+
else if (a === "--project") out.project = need(++i, "--project");
|
|
266
|
+
else if (a === "--report-json") out.reportJson = true;
|
|
267
|
+
else if (a === "--no-color") out.noColor = true;
|
|
268
|
+
else throw new UsageError(`Unknown flag: ${a}`);
|
|
269
|
+
}
|
|
270
|
+
if (out.minLength !== null && (!Number.isInteger(out.minLength) || out.minLength < 1)) {
|
|
271
|
+
throw new UsageError("--min-length must be a positive integer");
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
277
|
+
Input parsing — dotenv
|
|
278
|
+
═══════════════════════════════════════════════════════════════ */
|
|
279
|
+
|
|
280
|
+
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* dotenv parser.
|
|
284
|
+
*
|
|
285
|
+
* Scans by index rather than by line because a double-quoted value may span
|
|
286
|
+
* lines, and a PEM private key in a .env file is exactly that shape. Splitting
|
|
287
|
+
* on newlines first would read the first line of the key as the whole value and
|
|
288
|
+
* enroll a digest of a truncated secret, which looks enrolled and matches
|
|
289
|
+
* nothing.
|
|
290
|
+
*
|
|
291
|
+
* @returns Entries in file order (duplicates kept; the caller applies last-wins)
|
|
292
|
+
* plus human-readable problems for lines that were skipped.
|
|
293
|
+
*/
|
|
294
|
+
export function parseDotenv(text) {
|
|
295
|
+
const src = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
296
|
+
const entries = [];
|
|
297
|
+
const problems = [];
|
|
298
|
+
let i = 0;
|
|
299
|
+
let line = 1;
|
|
300
|
+
|
|
301
|
+
const skipToNextLine = () => {
|
|
302
|
+
const nl = src.indexOf("\n", i);
|
|
303
|
+
i = nl === -1 ? src.length : nl + 1;
|
|
304
|
+
};
|
|
305
|
+
const countNewlines = (from, to) => {
|
|
306
|
+
let n = 0;
|
|
307
|
+
for (let k = from; k < to; k++) if (src[k] === "\n") n++;
|
|
308
|
+
return n;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
while (i < src.length) {
|
|
312
|
+
const ch = src[i];
|
|
313
|
+
if (ch === "\n") {
|
|
314
|
+
line++;
|
|
315
|
+
i++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (ch === " " || ch === "\t") {
|
|
319
|
+
i++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (ch === "#") {
|
|
323
|
+
skipToNextLine();
|
|
324
|
+
line++;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const declLine = line;
|
|
329
|
+
|
|
330
|
+
let j = i;
|
|
331
|
+
while (j < src.length && src[j] !== "=" && src[j] !== "\n") j++;
|
|
332
|
+
if (j >= src.length || src[j] === "\n") {
|
|
333
|
+
problems.push({ line: declLine, message: "no '=' on this line — skipped" });
|
|
334
|
+
i = j + 1;
|
|
335
|
+
line++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let key = src.slice(i, j).trim();
|
|
340
|
+
if (/^export\s/.test(key)) key = key.replace(/^export\s+/, "").trim();
|
|
341
|
+
i = j + 1;
|
|
342
|
+
|
|
343
|
+
while (i < src.length && (src[i] === " " || src[i] === "\t")) i++;
|
|
344
|
+
|
|
345
|
+
let value = "";
|
|
346
|
+
let usable = true;
|
|
347
|
+
const quote = src[i];
|
|
348
|
+
|
|
349
|
+
if (quote === '"' || quote === "'") {
|
|
350
|
+
const valueStart = ++i;
|
|
351
|
+
const resumeAfterLine = src.indexOf("\n", i);
|
|
352
|
+
let closed = false;
|
|
353
|
+
while (i < src.length) {
|
|
354
|
+
const ch2 = src[i];
|
|
355
|
+
// Escapes are a double-quote feature in dotenv; inside single quotes a
|
|
356
|
+
// backslash is a literal backslash, and a key with a Windows path or a
|
|
357
|
+
// regex in it would otherwise be silently altered.
|
|
358
|
+
if (ch2 === "\\" && quote === '"' && i + 1 < src.length) {
|
|
359
|
+
const next = src[i + 1];
|
|
360
|
+
value += next === "n" ? "\n" : next === "t" ? "\t" : next === "r" ? "\r" : next;
|
|
361
|
+
i += 2;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (ch2 === quote) {
|
|
365
|
+
i++;
|
|
366
|
+
closed = true;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
value += ch2;
|
|
370
|
+
i++;
|
|
371
|
+
}
|
|
372
|
+
if (closed) {
|
|
373
|
+
line += countNewlines(valueStart, i);
|
|
374
|
+
skipToNextLine();
|
|
375
|
+
line++;
|
|
376
|
+
} else {
|
|
377
|
+
// An unterminated quote scans to end of file, so continuing from there
|
|
378
|
+
// would drop every key below it without saying so. Resuming on the next
|
|
379
|
+
// line loses only the broken entry, which is the one already reported.
|
|
380
|
+
problems.push({ line: declLine, message: "unterminated quote — skipped this entry" });
|
|
381
|
+
usable = false;
|
|
382
|
+
i = resumeAfterLine === -1 ? src.length : resumeAfterLine + 1;
|
|
383
|
+
line++;
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
const nl = src.indexOf("\n", i);
|
|
387
|
+
const end = nl === -1 ? src.length : nl;
|
|
388
|
+
let raw = src.slice(i, end);
|
|
389
|
+
// Only whitespace-then-# is a comment. `pass#word` is a password.
|
|
390
|
+
const hash = raw.search(/\s#/);
|
|
391
|
+
if (hash !== -1) raw = raw.slice(0, hash);
|
|
392
|
+
value = raw.trim();
|
|
393
|
+
i = end === src.length ? end : end + 1;
|
|
394
|
+
line++;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!usable) continue;
|
|
398
|
+
if (!KEY_PATTERN.test(key)) {
|
|
399
|
+
problems.push({ line: declLine, message: `unusable key name ${JSON.stringify(key)} — skipped` });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
entries.push({ key, value, line: declLine });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { entries, problems };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
409
|
+
Input parsing — JSON (and every vault CLI that speaks it)
|
|
410
|
+
═══════════════════════════════════════════════════════════════ */
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Keys that mark an envelope rather than a secret. Unwrapping only happens when
|
|
414
|
+
* the document's own keys are a subset of these, so a real `.env` that happens
|
|
415
|
+
* to contain a key called `data` is not mistaken for a Vault response.
|
|
416
|
+
*/
|
|
417
|
+
const ENVELOPE_KEYS = new Set([
|
|
418
|
+
"data",
|
|
419
|
+
"metadata",
|
|
420
|
+
"lease_id",
|
|
421
|
+
"lease_duration",
|
|
422
|
+
"lease_renewable",
|
|
423
|
+
"renewable",
|
|
424
|
+
"request_id",
|
|
425
|
+
"warnings",
|
|
426
|
+
"auth",
|
|
427
|
+
"wrap_info",
|
|
428
|
+
"mount_type",
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Peels vendor envelopes off a parsed JSON document.
|
|
435
|
+
*
|
|
436
|
+
* Vault nests under `data.data`, AWS hands back the payload as a JSON string in
|
|
437
|
+
* `SecretString`, and 1Password uses a `fields` array. Handling these here is
|
|
438
|
+
* what lets `--stdin` work with every vault without a single vendor SDK.
|
|
439
|
+
*
|
|
440
|
+
* @returns `{ shape, doc }` where shape names what was recognised, for the log.
|
|
441
|
+
*/
|
|
442
|
+
export function unwrapSecretsDocument(doc) {
|
|
443
|
+
if (!isPlainObject(doc)) {
|
|
444
|
+
throw new UsageError("Expected a JSON object of secrets, got " + describeJson(doc));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (typeof doc["SecretString"] === "string") {
|
|
448
|
+
let inner;
|
|
449
|
+
try {
|
|
450
|
+
inner = JSON.parse(doc["SecretString"]);
|
|
451
|
+
} catch {
|
|
452
|
+
throw new UsageError(
|
|
453
|
+
"SecretString is not JSON. For a single-value secret pass it to redaktyn-fingerprint instead."
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
return unwrapSecretsDocument(inner);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (Array.isArray(doc["fields"])) {
|
|
460
|
+
const map = {};
|
|
461
|
+
for (const f of doc["fields"]) {
|
|
462
|
+
if (!isPlainObject(f)) continue;
|
|
463
|
+
const key = f["label"] ?? f["id"];
|
|
464
|
+
if (typeof key !== "string" || typeof f["value"] !== "string") continue;
|
|
465
|
+
map[key] = f["value"];
|
|
466
|
+
}
|
|
467
|
+
return { shape: "1password-item", doc: map };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const own = Object.keys(doc);
|
|
471
|
+
const envelope = own.length > 0 && own.every((k) => ENVELOPE_KEYS.has(k));
|
|
472
|
+
if (envelope && isPlainObject(doc["data"])) {
|
|
473
|
+
const inner = doc["data"];
|
|
474
|
+
// Vault KV v2 puts the secrets one level deeper and a metadata block beside
|
|
475
|
+
// them; KV v1 has them right here.
|
|
476
|
+
if (isPlainObject(inner["data"]) && "metadata" in inner) {
|
|
477
|
+
return { shape: "vault-kv2", doc: inner["data"] };
|
|
478
|
+
}
|
|
479
|
+
return { shape: "vault-kv1", doc: inner };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { shape: "flat", doc };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function describeJson(v) {
|
|
486
|
+
if (v === null) return "null";
|
|
487
|
+
if (Array.isArray(v)) return "an array";
|
|
488
|
+
return typeof v;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Flattens an unwrapped document to entries.
|
|
493
|
+
*
|
|
494
|
+
* Non-string scalars are stringified rather than dropped so the preview reports
|
|
495
|
+
* a verdict for them; the filter is where `PORT=8080` gets rejected, and a value
|
|
496
|
+
* that vanished before the filter is a value the admin never got told about.
|
|
497
|
+
*/
|
|
498
|
+
export function entriesFromJsonDocument(doc) {
|
|
499
|
+
const entries = [];
|
|
500
|
+
const problems = [];
|
|
501
|
+
for (const [key, raw] of Object.entries(doc)) {
|
|
502
|
+
if (typeof raw === "string") {
|
|
503
|
+
entries.push({ key, value: raw });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (typeof raw === "number" || typeof raw === "boolean") {
|
|
507
|
+
entries.push({ key, value: String(raw) });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (isPlainObject(raw)) {
|
|
511
|
+
// Doppler and friends wrap each value in an object.
|
|
512
|
+
const field = ["value", "raw", "computed", "computedValue"].find(
|
|
513
|
+
(f) => typeof raw[f] === "string"
|
|
514
|
+
);
|
|
515
|
+
if (field) {
|
|
516
|
+
entries.push({ key, value: raw[field] });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
problems.push({ key, message: "nested object with no value/raw/computed field — skipped" });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
problems.push({ key, message: `${describeJson(raw)} is not a secret value — skipped` });
|
|
523
|
+
}
|
|
524
|
+
return { entries, problems };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function parseJsonSecrets(text) {
|
|
528
|
+
let doc;
|
|
529
|
+
try {
|
|
530
|
+
doc = JSON.parse(text);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
throw new UsageError(`Input is not valid JSON: ${e.message}`);
|
|
533
|
+
}
|
|
534
|
+
const { shape, doc: unwrapped } = unwrapSecretsDocument(doc);
|
|
535
|
+
const { entries, problems } = entriesFromJsonDocument(unwrapped);
|
|
536
|
+
return { entries, problems, shape };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
540
|
+
Input parsing — flat YAML subset
|
|
541
|
+
═══════════════════════════════════════════════════════════════ */
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Flat `key: value` YAML only.
|
|
545
|
+
*
|
|
546
|
+
* Real YAML is not parseable with a regex, and the failure mode of pretending
|
|
547
|
+
* otherwise is the worst one available here: a nested document read as flat
|
|
548
|
+
* produces keys mapped to empty or partial values, and enrolls digests of
|
|
549
|
+
* things that are not the secrets. So anything with indentation, a list, or a
|
|
550
|
+
* key with no inline value is refused outright.
|
|
551
|
+
*/
|
|
552
|
+
export function parseFlatYaml(text) {
|
|
553
|
+
const entries = [];
|
|
554
|
+
const problems = [];
|
|
555
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
556
|
+
|
|
557
|
+
lines.forEach((raw, idx) => {
|
|
558
|
+
const line = idx + 1;
|
|
559
|
+
if (raw.trim() === "" || raw.trim().startsWith("#")) return;
|
|
560
|
+
if (raw.trim() === "---" || raw.trim() === "...") return;
|
|
561
|
+
|
|
562
|
+
if (/^\s/.test(raw)) {
|
|
563
|
+
throw new UsageError(
|
|
564
|
+
`${line}: this YAML is indented, so it is not flat key/value. ` +
|
|
565
|
+
`Refusing rather than guessing — convert it to JSON and use --json.`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
if (/^-\s/.test(raw.trim()) || raw.trim() === "-") {
|
|
569
|
+
throw new UsageError(`${line}: YAML lists are not supported. Use --json.`);
|
|
570
|
+
}
|
|
571
|
+
if (/^[|>]/.test(raw.trim())) {
|
|
572
|
+
throw new UsageError(`${line}: YAML block scalars are not supported. Use --json.`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const m = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s?(.*)$/.exec(raw);
|
|
576
|
+
if (!m) {
|
|
577
|
+
problems.push({ line, message: "not a `key: value` line — skipped" });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const key = m[1];
|
|
581
|
+
let value = m[2] ?? "";
|
|
582
|
+
|
|
583
|
+
if (value.trim() === "" || value.trim() === "|" || value.trim() === ">") {
|
|
584
|
+
throw new UsageError(
|
|
585
|
+
`${line}: "${key}" has no inline value, which in YAML means a nested block or null. ` +
|
|
586
|
+
`Refusing rather than registering an empty fingerprint.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const t = value.trim();
|
|
591
|
+
if ((t.startsWith('"') && t.endsWith('"') && t.length > 1) ||
|
|
592
|
+
(t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
|
|
593
|
+
value = t.slice(1, -1);
|
|
594
|
+
if (t[0] === '"') value = value.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\(.)/g, "$1");
|
|
595
|
+
} else {
|
|
596
|
+
const hash = t.search(/\s#/);
|
|
597
|
+
value = (hash === -1 ? t : t.slice(0, hash)).trim();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
entries.push({ key, value, line });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
return { entries, problems };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
607
|
+
Junk filter
|
|
608
|
+
═══════════════════════════════════════════════════════════════ */
|
|
609
|
+
|
|
610
|
+
export const DEFAULT_MIN_LENGTH = 12;
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Values that are configuration or placeholders whatever their length. Enrolling
|
|
614
|
+
* one of these is not a harmless extra: every paste containing the word would be
|
|
615
|
+
* blocked, and zero false positives is the whole product.
|
|
616
|
+
*/
|
|
617
|
+
const PLACEHOLDER_VALUES = new Set([
|
|
618
|
+
"changeme",
|
|
619
|
+
"change-me",
|
|
620
|
+
"change_me",
|
|
621
|
+
"replaceme",
|
|
622
|
+
"replace-me",
|
|
623
|
+
"your-secret-here",
|
|
624
|
+
"your_secret_here",
|
|
625
|
+
"yoursecrethere",
|
|
626
|
+
"development",
|
|
627
|
+
"production",
|
|
628
|
+
"staging",
|
|
629
|
+
"localhost",
|
|
630
|
+
"password",
|
|
631
|
+
"passw0rd",
|
|
632
|
+
"secret",
|
|
633
|
+
"topsecret",
|
|
634
|
+
"undefined",
|
|
635
|
+
"none",
|
|
636
|
+
"null",
|
|
637
|
+
"n/a",
|
|
638
|
+
"todo",
|
|
639
|
+
"tbd",
|
|
640
|
+
"example",
|
|
641
|
+
"test",
|
|
642
|
+
"dummy",
|
|
643
|
+
"placeholder",
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
function shannonBitsPerChar(s) {
|
|
647
|
+
if (s.length === 0) return 0;
|
|
648
|
+
const counts = new Map();
|
|
649
|
+
for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
650
|
+
let h = 0;
|
|
651
|
+
for (const n of counts.values()) {
|
|
652
|
+
const p = n / s.length;
|
|
653
|
+
h -= p * Math.log2(p);
|
|
654
|
+
}
|
|
655
|
+
return h;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* URLs are the one case where "looks like config" and "is a secret" overlap: a
|
|
660
|
+
* Slack or Discord webhook is a URL and nothing else, and a Postgres DSN carries
|
|
661
|
+
* its password inline. So a URL is kept when it embeds a credential or a
|
|
662
|
+
* token-shaped path segment, and rejected when it is just an address.
|
|
663
|
+
*
|
|
664
|
+
* @returns null when the value is not a URL at all.
|
|
665
|
+
*/
|
|
666
|
+
function urlVerdict(value) {
|
|
667
|
+
if (!/:\/\//.test(value)) return null;
|
|
668
|
+
let u;
|
|
669
|
+
try {
|
|
670
|
+
u = new URL(value);
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
if (u.password) return { ok: true };
|
|
675
|
+
const parts = [
|
|
676
|
+
...u.pathname.split("/"),
|
|
677
|
+
...[...u.searchParams.values()],
|
|
678
|
+
u.hash.replace(/^#/, ""),
|
|
679
|
+
].filter(Boolean);
|
|
680
|
+
if (parts.some((p) => p.length >= 16 && shannonBitsPerChar(p) >= 2.5)) return { ok: true };
|
|
681
|
+
return { ok: false, reason: "URL with no embedded credential or token" };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Decides whether a value looks like a secret worth enforcing.
|
|
686
|
+
*
|
|
687
|
+
* Advisory by design: every rejection is printed with its reason and can be
|
|
688
|
+
* overridden with --allow-weak or --min-length. The bias is toward rejecting,
|
|
689
|
+
* because a missed secret can be enrolled tomorrow while a bad enrollment
|
|
690
|
+
* starts blocking legitimate pastes org-wide the moment it syncs.
|
|
691
|
+
*
|
|
692
|
+
* @param value - Canonical form, as digested
|
|
693
|
+
*/
|
|
694
|
+
export function junkVerdict(value, { minLength = DEFAULT_MIN_LENGTH } = {}) {
|
|
695
|
+
if (value.length === 0) return { ok: false, reason: "empty value" };
|
|
696
|
+
if (value.length < minLength) {
|
|
697
|
+
return { ok: false, reason: `only ${value.length} chars (minimum ${minLength})` };
|
|
698
|
+
}
|
|
699
|
+
if (/^\d+$/.test(value)) return { ok: false, reason: "all digits — port, id or timestamp" };
|
|
700
|
+
if (/^(true|false|yes|no|on|off|null|nil|none|undefined|enabled|disabled)$/i.test(value)) {
|
|
701
|
+
return { ok: false, reason: "boolean or empty-ish flag" };
|
|
702
|
+
}
|
|
703
|
+
if (/^v?\d+(\.\d+){1,3}([-+][0-9A-Za-z.-]+)?$/.test(value)) {
|
|
704
|
+
return { ok: false, reason: "version number" };
|
|
705
|
+
}
|
|
706
|
+
if (PLACEHOLDER_VALUES.has(value.toLowerCase())) {
|
|
707
|
+
return { ok: false, reason: "placeholder or environment name" };
|
|
708
|
+
}
|
|
709
|
+
if (/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value)) {
|
|
710
|
+
return { ok: false, reason: "email address" };
|
|
711
|
+
}
|
|
712
|
+
if (/^(\/[^\s]*|\.{1,2}\/[^\s]*|~\/[^\s]*|[A-Za-z]:[\\/][^\s]*)$/.test(value)) {
|
|
713
|
+
return { ok: false, reason: "filesystem path" };
|
|
714
|
+
}
|
|
715
|
+
const url = urlVerdict(value);
|
|
716
|
+
if (url && !url.ok) return { ok: false, reason: url.reason };
|
|
717
|
+
if (!url) {
|
|
718
|
+
const distinct = new Set(value).size;
|
|
719
|
+
if (distinct < 5) return { ok: false, reason: `only ${distinct} distinct characters` };
|
|
720
|
+
if (shannonBitsPerChar(value) < 2.0) {
|
|
721
|
+
return { ok: false, reason: "very low character diversity" };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return { ok: true };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
728
|
+
Source selection and key filtering
|
|
729
|
+
═══════════════════════════════════════════════════════════════ */
|
|
730
|
+
|
|
731
|
+
async function readStdin() {
|
|
732
|
+
if (process.stdin.isTTY) {
|
|
733
|
+
throw new UsageError("--stdin was passed but nothing is piped in.");
|
|
734
|
+
}
|
|
735
|
+
const chunks = [];
|
|
736
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
737
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function readFileOrDie(path, flag) {
|
|
741
|
+
try {
|
|
742
|
+
return readFileSync(path, "utf8");
|
|
743
|
+
} catch (e) {
|
|
744
|
+
throw new UsageError(`${flag} ${path}: ${e.code === "ENOENT" ? "no such file" : e.message}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** `import:<source>`; capped so the tag stays inside the server's 80-char limit. */
|
|
749
|
+
export function sourceTag(source) {
|
|
750
|
+
return `import:${source}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function normalizeSource(raw) {
|
|
754
|
+
const cleaned = String(raw)
|
|
755
|
+
.replace(/^[./]+/, "")
|
|
756
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
757
|
+
.replace(/^-+|-+$/g, "")
|
|
758
|
+
.slice(0, 60);
|
|
759
|
+
return cleaned || "import";
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function matchesAny(key, patterns) {
|
|
763
|
+
return patterns.some((p) => {
|
|
764
|
+
if (p.includes("*")) {
|
|
765
|
+
const re = new RegExp("^" + p.split("*").map(escapeRegex).join(".*") + "$", "i");
|
|
766
|
+
return re.test(key);
|
|
767
|
+
}
|
|
768
|
+
return key.toLowerCase() === p.toLowerCase();
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
773
|
+
|
|
774
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
775
|
+
Diff
|
|
776
|
+
═══════════════════════════════════════════════════════════════ */
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Classifies each candidate against what the org already enforces.
|
|
780
|
+
*
|
|
781
|
+
* Identity is `label` inside the import's own source tag, so two imports from
|
|
782
|
+
* two vaults never fight over the same row. Three deliberate choices:
|
|
783
|
+
*
|
|
784
|
+
* - A value already enrolled under any label is a SKIP, not a CREATE. POST
|
|
785
|
+
* rejects a duplicate digest with 409, so attempting it would turn a normal
|
|
786
|
+
* situation into a failed run.
|
|
787
|
+
* - A same-label secret that is *not* from this source is a CONFLICT, reported
|
|
788
|
+
* and left alone. Rotating it would silently take over a hand-enrolled secret.
|
|
789
|
+
* - A secret from this source that is no longer in the file is reported and
|
|
790
|
+
* never removed. A vault read can fail or return a subset, and the failure
|
|
791
|
+
* mode of acting on that is org-wide loss of protection.
|
|
792
|
+
*/
|
|
793
|
+
export function diffAgainstServer({ candidates, existing, source }) {
|
|
794
|
+
const tag = sourceTag(source);
|
|
795
|
+
const byDigest = new Map();
|
|
796
|
+
for (const s of existing) if (s.hmacHex) byDigest.set(s.hmacHex, s);
|
|
797
|
+
|
|
798
|
+
const owned = existing.filter((s) => Array.isArray(s.tags) && s.tags.includes(tag));
|
|
799
|
+
const ownedByLabel = new Map(owned.map((s) => [s.label, s]));
|
|
800
|
+
const otherByLabel = new Map();
|
|
801
|
+
for (const s of existing) {
|
|
802
|
+
if (owned.includes(s)) continue;
|
|
803
|
+
if (!otherByLabel.has(s.label)) otherByLabel.set(s.label, s);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const plan = [];
|
|
807
|
+
const seenLabels = new Set();
|
|
808
|
+
|
|
809
|
+
for (const cand of candidates) {
|
|
810
|
+
const { key, digests } = cand;
|
|
811
|
+
seenLabels.add(key);
|
|
812
|
+
|
|
813
|
+
const mine = ownedByLabel.get(key);
|
|
814
|
+
if (mine) {
|
|
815
|
+
if (mine.hmacHex === digests.digest && variantsMatch(mine, digests)) {
|
|
816
|
+
plan.push({ key, action: "skip", reason: "already registered and unchanged", id: mine.id, length: digests.length });
|
|
817
|
+
} else {
|
|
818
|
+
const clash = byDigest.get(digests.digest);
|
|
819
|
+
if (clash && clash.id !== mine.id) {
|
|
820
|
+
plan.push({
|
|
821
|
+
key,
|
|
822
|
+
action: "conflict",
|
|
823
|
+
reason: `new value is already registered as "${clash.label}"`,
|
|
824
|
+
id: mine.id,
|
|
825
|
+
length: digests.length,
|
|
826
|
+
});
|
|
827
|
+
} else {
|
|
828
|
+
const reason =
|
|
829
|
+
mine.hmacHex === digests.digest ? "value unchanged; adding missing variants" : "value changed";
|
|
830
|
+
plan.push({ key, action: "rotate", reason, id: mine.id, length: digests.length });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const sameDigest = byDigest.get(digests.digest);
|
|
837
|
+
if (sameDigest) {
|
|
838
|
+
plan.push({
|
|
839
|
+
key,
|
|
840
|
+
action: "skip",
|
|
841
|
+
reason: `already registered as "${sameDigest.label}"`,
|
|
842
|
+
id: sameDigest.id,
|
|
843
|
+
length: digests.length,
|
|
844
|
+
});
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const sameLabel = otherByLabel.get(key);
|
|
849
|
+
if (sameLabel) {
|
|
850
|
+
plan.push({
|
|
851
|
+
key,
|
|
852
|
+
action: "conflict",
|
|
853
|
+
reason: "a secret with this label exists outside this import source",
|
|
854
|
+
id: sameLabel.id,
|
|
855
|
+
length: digests.length,
|
|
856
|
+
});
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
plan.push({ key, action: "create", reason: "new", length: digests.length });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const missing = owned
|
|
864
|
+
.filter((s) => !seenLabels.has(s.label))
|
|
865
|
+
.map((s) => ({ label: s.label, id: s.id, status: s.status }));
|
|
866
|
+
|
|
867
|
+
return { plan, missing };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* True when the stored bypass variants already equal the ones just computed.
|
|
872
|
+
*
|
|
873
|
+
* Without this, a secret enrolled by an older client that stored only the exact
|
|
874
|
+
* digest would compare equal on `hmacHex` and be skipped forever, so the
|
|
875
|
+
* whitespace and case-fold passes would never start covering it.
|
|
876
|
+
*
|
|
877
|
+
* The scan prefilter is compared for the same reason: a row enrolled before it
|
|
878
|
+
* existed is otherwise skipped forever and keeps costing devices a full sweep.
|
|
879
|
+
* Re-importing is the one way an existing secret can gain it, because this is
|
|
880
|
+
* the one tool that holds the plaintext again.
|
|
881
|
+
*/
|
|
882
|
+
function variantsMatch(existing, digests) {
|
|
883
|
+
return (
|
|
884
|
+
existing.hmacHexStripped === digests.hmacHexStripped &&
|
|
885
|
+
existing.hmacHexLower === digests.hmacHexLower &&
|
|
886
|
+
existing.scanPrefilter === digests.scanPrefilter
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
891
|
+
HTTP
|
|
892
|
+
═══════════════════════════════════════════════════════════════ */
|
|
893
|
+
|
|
894
|
+
const trimSlashes = (u) => u.replace(/\/+$/, "");
|
|
895
|
+
|
|
896
|
+
async function apiRequest(serverUrl, token, method, path, body) {
|
|
897
|
+
let res;
|
|
898
|
+
try {
|
|
899
|
+
res = await fetch(`${trimSlashes(serverUrl)}${path}`, {
|
|
900
|
+
method,
|
|
901
|
+
headers: {
|
|
902
|
+
Authorization: `Bearer ${token}`,
|
|
903
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
904
|
+
},
|
|
905
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
906
|
+
});
|
|
907
|
+
} catch (e) {
|
|
908
|
+
throw new UsageError(`Cannot reach ${trimSlashes(serverUrl)}: ${e.message}`);
|
|
909
|
+
}
|
|
910
|
+
const text = await res.text();
|
|
911
|
+
let data = null;
|
|
912
|
+
if (text) {
|
|
913
|
+
try {
|
|
914
|
+
data = JSON.parse(text);
|
|
915
|
+
} catch {
|
|
916
|
+
data = null;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { status: res.status, ok: res.ok, data, text };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Resolve --project to a row of GET /api/projects: exact id, else a
|
|
924
|
+
* case-insensitive exact name. Refuses an unknown, ambiguous or closed project
|
|
925
|
+
* before anything is hashed, because the server would otherwise file digests
|
|
926
|
+
* under a key no device on that project derives.
|
|
927
|
+
*/
|
|
928
|
+
async function resolveProject(serverUrl, token, wanted) {
|
|
929
|
+
const r = await apiRequest(serverUrl, token, "GET", "/api/projects");
|
|
930
|
+
if (!r.ok || !Array.isArray(r.data)) {
|
|
931
|
+
throw new UsageError(`GET /api/projects failed: ${r.status} ${String(r.text).slice(0, 200)}`);
|
|
932
|
+
}
|
|
933
|
+
const lower = wanted.trim().toLowerCase();
|
|
934
|
+
const byId = r.data.filter((p) => p.id === wanted.trim());
|
|
935
|
+
const matches = byId.length ? byId : r.data.filter((p) => String(p.name).toLowerCase() === lower);
|
|
936
|
+
if (matches.length === 0) {
|
|
937
|
+
throw new UsageError(`No project named or numbered "${wanted}". Known: ${r.data.map((p) => p.name).join(", ") || "none"}.`);
|
|
938
|
+
}
|
|
939
|
+
if (matches.length > 1) {
|
|
940
|
+
throw new UsageError(`"${wanted}" matches ${matches.length} projects. Pass the project id instead.`);
|
|
941
|
+
}
|
|
942
|
+
if (matches[0].status === "CLOSED") {
|
|
943
|
+
throw new UsageError(`Project "${matches[0].name}" is closed. Reopen it before enrolling into it.`);
|
|
944
|
+
}
|
|
945
|
+
return matches[0];
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function fetchExistingSecrets(serverUrl, token) {
|
|
949
|
+
const r = await apiRequest(serverUrl, token, "GET", "/api/secrets");
|
|
950
|
+
if (r.status === 401) {
|
|
951
|
+
throw new UsageError(
|
|
952
|
+
"Server rejected the token (401). REDAKTYN_TOKEN must be a current OWNER or ADMIN login token."
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
if (r.status === 403) {
|
|
956
|
+
throw new UsageError(
|
|
957
|
+
"This token is not allowed to manage secrets (403). Importing needs an OWNER or ADMIN token."
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
if (!r.ok) throw new UsageError(`GET /api/secrets failed: ${r.status} ${r.text.slice(0, 200)}`);
|
|
961
|
+
if (!Array.isArray(r.data)) throw new UsageError("GET /api/secrets did not return a list.");
|
|
962
|
+
return r.data;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
966
|
+
Token checks
|
|
967
|
+
═══════════════════════════════════════════════════════════════ */
|
|
968
|
+
|
|
969
|
+
export function decodeJwtPayload(token) {
|
|
970
|
+
try {
|
|
971
|
+
const p = token.split(".")[1];
|
|
972
|
+
if (!p) return null;
|
|
973
|
+
const padded = p + "=".repeat((4 - (p.length % 4)) % 4);
|
|
974
|
+
const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
975
|
+
return JSON.parse(json);
|
|
976
|
+
} catch {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Refuses an extension token before anything is computed.
|
|
983
|
+
*
|
|
984
|
+
* `redaktyn-scan` runs on a device token, so that is the variable already
|
|
985
|
+
* exported in most shells that have used this CLI. An extension token can read
|
|
986
|
+
* the secret list, which means the whole dry-run would look like it worked and
|
|
987
|
+
* only the first write would fail — after the operator had approved the plan.
|
|
988
|
+
*/
|
|
989
|
+
export function tokenRoleProblem(payload) {
|
|
990
|
+
if (!payload) return null;
|
|
991
|
+
if (payload.type === "extension") {
|
|
992
|
+
return (
|
|
993
|
+
"REDAKTYN_TOKEN is an extension device token. Those can sync fingerprints but not register them.\n" +
|
|
994
|
+
" Get an OWNER or ADMIN login token instead:\n" +
|
|
995
|
+
" curl -s -X POST \"$REDAKTYN_SERVER_URL/api/auth/login\" \\\n" +
|
|
996
|
+
" -H 'Content-Type: application/json' \\\n" +
|
|
997
|
+
" -d '{\"email\":\"you@example.com\",\"password\":\"…\"}' | jq -r .token"
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
const role = payload.role;
|
|
1001
|
+
if (typeof role === "string" && !["OWNER", "ADMIN"].includes(role.toUpperCase())) {
|
|
1002
|
+
return `This token has role ${role}. Importing needs OWNER or ADMIN.`;
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
1008
|
+
Output
|
|
1009
|
+
═══════════════════════════════════════════════════════════════ */
|
|
1010
|
+
|
|
1011
|
+
const ACTION_STYLE = {
|
|
1012
|
+
create: { label: "CREATE", color: c.green },
|
|
1013
|
+
rotate: { label: "ROTATE", color: c.yellow },
|
|
1014
|
+
skip: { label: "skip", color: c.gray },
|
|
1015
|
+
conflict: { label: "CONFLICT", color: c.red },
|
|
1016
|
+
rejected: { label: "rejected", color: c.gray },
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
function truncateKey(key, width) {
|
|
1020
|
+
return key.length <= width ? key.padEnd(width) : key.slice(0, width - 1) + "…";
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Prints the plan.
|
|
1025
|
+
*
|
|
1026
|
+
* Values are never printed — not truncated, not masked, not for rejected keys.
|
|
1027
|
+
* A preview is the thing an operator screenshots or pipes into a CI log, and a
|
|
1028
|
+
* tool whose whole claim is that plaintext stays local cannot be the one to put
|
|
1029
|
+
* plaintext on a terminal.
|
|
1030
|
+
*/
|
|
1031
|
+
function printPlan({ rows, missing, source, dryRun, budget, projectName = null }) {
|
|
1032
|
+
const width = Math.min(process.stdout.columns || 80, 100);
|
|
1033
|
+
const keyWidth = 34;
|
|
1034
|
+
|
|
1035
|
+
console.log("");
|
|
1036
|
+
console.log(`${c.bold}Redaktyn import${c.reset} ${c.dim}· source tag ${sourceTag(source)}${projectName ? ` · project ${projectName}` : ""}${c.reset}`);
|
|
1037
|
+
console.log(c.dim + "─".repeat(width) + c.reset);
|
|
1038
|
+
console.log(
|
|
1039
|
+
`${c.dim}${"KEY".padEnd(keyWidth)} ${"LEN".padStart(5)} ${"ACTION".padEnd(9)} WHY${c.reset}`
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
for (const row of rows) {
|
|
1043
|
+
const style = ACTION_STYLE[row.action] ?? { label: row.action, color: "" };
|
|
1044
|
+
console.log(
|
|
1045
|
+
`${truncateKey(row.key, keyWidth)} ${String(row.length ?? "-").padStart(5)} ` +
|
|
1046
|
+
`${style.color}${style.label.padEnd(9)}${c.reset} ${c.dim}${row.reason ?? ""}${c.reset}`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
console.log(c.dim + "─".repeat(width) + c.reset);
|
|
1051
|
+
|
|
1052
|
+
const counts = tally(rows);
|
|
1053
|
+
const summary = [
|
|
1054
|
+
counts.create ? `${c.green}${counts.create} to create${c.reset}` : null,
|
|
1055
|
+
counts.rotate ? `${c.yellow}${counts.rotate} to rotate${c.reset}` : null,
|
|
1056
|
+
counts.skip ? `${counts.skip} unchanged` : null,
|
|
1057
|
+
counts.rejected ? `${counts.rejected} filtered out` : null,
|
|
1058
|
+
counts.conflict ? `${c.red}${counts.conflict} conflicting${c.reset}` : null,
|
|
1059
|
+
].filter(Boolean);
|
|
1060
|
+
console.log(" " + (summary.length ? summary.join(c.dim + " · " + c.reset) : "nothing to do"));
|
|
1061
|
+
|
|
1062
|
+
if (missing.length > 0) {
|
|
1063
|
+
console.log("");
|
|
1064
|
+
console.log(
|
|
1065
|
+
`${c.yellow}${missing.length} secret(s) tagged ${sourceTag(source)} are not in this input.${c.reset}`
|
|
1066
|
+
);
|
|
1067
|
+
console.log(`${c.dim} Reported only — import never removes anything. Retire them in the dashboard if they are gone.${c.reset}`);
|
|
1068
|
+
for (const m of missing.slice(0, 20)) {
|
|
1069
|
+
console.log(`${c.dim} · ${m.label}${c.reset}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (missing.length > 20) console.log(`${c.dim} · … ${missing.length - 20} more${c.reset}`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
printBudget(budget);
|
|
1075
|
+
|
|
1076
|
+
if (dryRun) {
|
|
1077
|
+
console.log("");
|
|
1078
|
+
console.log(`${c.bold}Dry run — nothing was sent.${c.reset} ${c.dim}Re-run with --apply to register.${c.reset}`);
|
|
1079
|
+
}
|
|
1080
|
+
console.log("");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Reports the paste ceiling this import leaves behind.
|
|
1085
|
+
*
|
|
1086
|
+
* Always printed, because every enrolled secret moves it and an operator
|
|
1087
|
+
* running a bulk import is the only person in a position to trade coverage
|
|
1088
|
+
* against it. Escalated when it reaches sizes people actually paste.
|
|
1089
|
+
*/
|
|
1090
|
+
function printBudget(budget) {
|
|
1091
|
+
if (!budget || budget.ceiling === Infinity) return;
|
|
1092
|
+
const pretty = budget.ceiling.toLocaleString("en-US");
|
|
1093
|
+
const legacyNote = (tone) => {
|
|
1094
|
+
if (!(budget.legacySecrets > 0) || !(budget.ceilingIfReenrolled > budget.ceiling)) return;
|
|
1095
|
+
const n = budget.legacySecrets;
|
|
1096
|
+
console.log(
|
|
1097
|
+
`${tone} ${n} enrolled secret${n === 1 ? " has" : "s have"} no scan prefilter (enrolled before 2026-10-07 or by an` +
|
|
1098
|
+
` older client),${c.reset}`
|
|
1099
|
+
);
|
|
1100
|
+
console.log(
|
|
1101
|
+
`${tone} so every paste is checked against ${n === 1 ? "it" : "them"} the slow way. They are outside this import;` +
|
|
1102
|
+
` re-enrolling ${n === 1 ? "it" : "them"}${c.reset}`
|
|
1103
|
+
);
|
|
1104
|
+
console.log(
|
|
1105
|
+
`${tone} from the dashboard, or importing ${n === 1 ? "it" : "them"}, raises this to ~` +
|
|
1106
|
+
`${budget.ceilingIfReenrolled.toLocaleString("en-US")} chars.${c.reset}`
|
|
1107
|
+
);
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
if (budget.ceiling < PASTE_CEILING_ALARM_CHARS) {
|
|
1111
|
+
console.log("");
|
|
1112
|
+
console.log(`${c.red}${c.bold}Scan budget: pastes over ~${pretty} characters will be refused${c.reset}`);
|
|
1113
|
+
console.log(
|
|
1114
|
+
` ${budget.windowLengths} distinct window lengths after this import. Detection slides one pass per`
|
|
1115
|
+
);
|
|
1116
|
+
console.log(
|
|
1117
|
+
` length, so past that size the extension ${c.bold}refuses to scan${c.reset} rather than hang —`
|
|
1118
|
+
);
|
|
1119
|
+
console.log(` which is a hard block in sensitive contexts. A paste at the ceiling takes up to ~8s.`);
|
|
1120
|
+
legacyNote("");
|
|
1121
|
+
console.log(`${c.dim} Register the high-value secrets only, or split them across workspaces.${c.reset}`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (budget.ceiling < PASTE_CEILING_WARN_CHARS) {
|
|
1126
|
+
console.log("");
|
|
1127
|
+
console.log(
|
|
1128
|
+
`${c.yellow}Scan budget:${c.reset} ${budget.windowLengths} window lengths after this import; ` +
|
|
1129
|
+
`pastes above ~${pretty} chars are refused.`
|
|
1130
|
+
);
|
|
1131
|
+
legacyNote("");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
console.log(
|
|
1136
|
+
budget.atCharacterCap
|
|
1137
|
+
? `${c.dim} Scan budget: every paste up to the extension's ${pretty}-char hard limit is scanned.${c.reset}`
|
|
1138
|
+
: `${c.dim} Scan budget: ${budget.windowLengths} window lengths, pastes up to ~${pretty} chars.${c.reset}`
|
|
1139
|
+
);
|
|
1140
|
+
legacyNote(c.dim);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function tally(rows) {
|
|
1144
|
+
const counts = { create: 0, rotate: 0, skip: 0, conflict: 0, rejected: 0 };
|
|
1145
|
+
for (const r of rows) counts[r.action] = (counts[r.action] ?? 0) + 1;
|
|
1146
|
+
return counts;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
1150
|
+
Confirmation
|
|
1151
|
+
═══════════════════════════════════════════════════════════════ */
|
|
1152
|
+
|
|
1153
|
+
async function confirm(question) {
|
|
1154
|
+
if (!process.stdin.isTTY) {
|
|
1155
|
+
throw new UsageError("--apply needs a terminal to confirm on, or --yes to skip the prompt.");
|
|
1156
|
+
}
|
|
1157
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1158
|
+
try {
|
|
1159
|
+
const answer = await new Promise((resolve) => rl.question(`${question} `, resolve));
|
|
1160
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
1161
|
+
} finally {
|
|
1162
|
+
rl.close();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function promptHidden(question) {
|
|
1167
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1168
|
+
return new Promise((resolve) => {
|
|
1169
|
+
const onData = (chunk) => {
|
|
1170
|
+
// Repaint the prompt without the typed characters. readline has already
|
|
1171
|
+
// echoed them, so this erases the line rather than trying to prevent it.
|
|
1172
|
+
const s = String(chunk);
|
|
1173
|
+
if (!s.includes("\n") && !s.includes("\r")) {
|
|
1174
|
+
readline.clearLine(process.stdout, 0);
|
|
1175
|
+
readline.cursorTo(process.stdout, 0);
|
|
1176
|
+
process.stdout.write(question + " ");
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
process.stdin.on("data", onData);
|
|
1180
|
+
rl.question(`${question} `, (answer) => {
|
|
1181
|
+
process.stdin.off("data", onData);
|
|
1182
|
+
rl.close();
|
|
1183
|
+
process.stdout.write("\n");
|
|
1184
|
+
resolve(answer);
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
1190
|
+
Apply
|
|
1191
|
+
═══════════════════════════════════════════════════════════════ */
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* The exact field set the secrets API accepts, built through the shared
|
|
1195
|
+
* enrollPayload() so this cannot drift into sending only the exact digest and
|
|
1196
|
+
* quietly losing the whitespace and case-fold coverage.
|
|
1197
|
+
*
|
|
1198
|
+
* Named fields rather than a spread of the compute result, which also carries
|
|
1199
|
+
* `canonical` — the plaintext. It would be dropped by enrollPayload anyway, but
|
|
1200
|
+
* a tool that promises values stay local should not pass them into a request
|
|
1201
|
+
* builder at all.
|
|
1202
|
+
*/
|
|
1203
|
+
function digestBody(label, d) {
|
|
1204
|
+
return enrollPayload({
|
|
1205
|
+
label,
|
|
1206
|
+
hmacHex: d.digest,
|
|
1207
|
+
length: d.length,
|
|
1208
|
+
hmacHexStripped: d.hmacHexStripped,
|
|
1209
|
+
lengthStripped: d.lengthStripped,
|
|
1210
|
+
hmacHexLower: d.hmacHexLower,
|
|
1211
|
+
lengthLower: d.lengthLower,
|
|
1212
|
+
scanPrefilter: d.scanPrefilter,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function applyPlan({ rows, byKey, serverUrl, token, source, projectId = null }) {
|
|
1217
|
+
const results = [];
|
|
1218
|
+
const tag = sourceTag(source);
|
|
1219
|
+
let sendPrefilter = true;
|
|
1220
|
+
|
|
1221
|
+
for (const row of rows) {
|
|
1222
|
+
if (row.action !== "create" && row.action !== "rotate") continue;
|
|
1223
|
+
const cand = byKey.get(row.key);
|
|
1224
|
+
|
|
1225
|
+
const send = (withPrefilter) => {
|
|
1226
|
+
const { scanPrefilter, ...rest } = cand.digests;
|
|
1227
|
+
const payload = digestBody(row.key, withPrefilter ? { ...rest, scanPrefilter } : rest);
|
|
1228
|
+
// The digest replacement body is strict and has no `label` — replacing a
|
|
1229
|
+
// fingerprint is deliberately not a rename — so the label is dropped here
|
|
1230
|
+
// rather than sent and rejected as an unrecognised key.
|
|
1231
|
+
const { label: _label, ...digestsOnly } = payload;
|
|
1232
|
+
// The server refuses a digest filed under a project it was not hashed for,
|
|
1233
|
+
// so the project travels with every write.
|
|
1234
|
+
const scope = projectId ? { projectId } : {};
|
|
1235
|
+
return row.action === "create"
|
|
1236
|
+
? apiRequest(serverUrl, token, "POST", "/api/secrets", { ...payload, ...scope, tags: [tag] })
|
|
1237
|
+
: apiRequest(serverUrl, token, "PUT", `/api/secrets/${row.id}/digest`, { ...digestsOnly, ...scope });
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
let r = await send(sendPrefilter);
|
|
1241
|
+
if (sendPrefilter && cand.digests.scanPrefilter && refusesOnlyScanPrefilter(r)) {
|
|
1242
|
+
// Only reachable on an empty vault, where the plan could not tell the
|
|
1243
|
+
// server's age from its rows. Nothing was written by the refused request.
|
|
1244
|
+
sendPrefilter = false;
|
|
1245
|
+
printOlderServerNotice();
|
|
1246
|
+
r = await send(false);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (r.ok) {
|
|
1250
|
+
results.push({ key: row.key, action: row.action, ok: true });
|
|
1251
|
+
console.log(` ${c.green}✓${c.reset} ${row.action === "create" ? "created" : "rotated"} ${row.key}`);
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const reason = explainWriteFailure(r);
|
|
1256
|
+
results.push({ key: row.key, action: row.action, ok: false, status: r.status, reason });
|
|
1257
|
+
console.log(` ${c.red}✗${c.reset} ${row.key}: ${reason}`);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return results;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function printOlderServerNotice() {
|
|
1264
|
+
console.log(
|
|
1265
|
+
`${c.yellow}!${c.reset} This server does not store scan prefilters yet, so secrets are registered without one.`
|
|
1266
|
+
);
|
|
1267
|
+
console.log(
|
|
1268
|
+
`${c.dim} They are protected exactly the same, but every paste checks them the slow way. Re-run this${c.reset}`
|
|
1269
|
+
);
|
|
1270
|
+
console.log(`${c.dim} import once the server is updated; it adds the prefilter to each of them.${c.reset}`);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function explainWriteFailure(r) {
|
|
1274
|
+
if (r.status === 409) {
|
|
1275
|
+
return "another secret in this workspace already has this fingerprint";
|
|
1276
|
+
}
|
|
1277
|
+
if (r.status === 403) return "token is not OWNER or ADMIN";
|
|
1278
|
+
if (r.status === 401) return "token expired or invalid";
|
|
1279
|
+
if (r.status === 404) return "secret no longer exists — re-run to re-plan";
|
|
1280
|
+
const msg = r.data && typeof r.data.error === "string" ? r.data.error : r.text.slice(0, 160);
|
|
1281
|
+
return `${r.status} ${msg}`;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
1285
|
+
Help
|
|
1286
|
+
═══════════════════════════════════════════════════════════════ */
|
|
1287
|
+
function printHelp() {
|
|
1288
|
+
console.log(`
|
|
1289
|
+
${c.bold}redaktyn-import${c.reset} — register fingerprints in bulk from files or any vault CLI
|
|
1290
|
+
|
|
1291
|
+
${c.bold}Digests are computed on this machine.${c.reset} Values are never sent, never
|
|
1292
|
+
printed, and never written to the report.
|
|
1293
|
+
|
|
1294
|
+
${c.bold}Input (choose one)${c.reset}
|
|
1295
|
+
--env <file> dotenv file (export prefix, quotes, comments, multi-line)
|
|
1296
|
+
--json <file> JSON object of secrets
|
|
1297
|
+
--yaml <file> flat "key: value" YAML only; refuses anything nested
|
|
1298
|
+
--stdin JSON piped in — this is how vault CLIs are consumed
|
|
1299
|
+
|
|
1300
|
+
${c.bold}Vault recipes${c.reset}
|
|
1301
|
+
vault kv get -format=json secret/prod | redaktyn-import --stdin
|
|
1302
|
+
doppler secrets download --no-file --format json | redaktyn-import --stdin
|
|
1303
|
+
op item get prod --format json | redaktyn-import --stdin
|
|
1304
|
+
aws secretsmanager get-secret-value --secret-id prod \\
|
|
1305
|
+
--query SecretString --output text | redaktyn-import --stdin
|
|
1306
|
+
|
|
1307
|
+
${c.bold}Options${c.reset}
|
|
1308
|
+
--source <name> Provenance tag, stored as import:<name>
|
|
1309
|
+
(default: input file name, or "stdin")
|
|
1310
|
+
--apply Actually register. Without it this is a dry run.
|
|
1311
|
+
--yes Skip the confirmation prompt (for CI)
|
|
1312
|
+
--min-length <n> Minimum value length to accept (default ${DEFAULT_MIN_LENGTH})
|
|
1313
|
+
--allow-weak Register values the junk filter rejected
|
|
1314
|
+
--only <key|glob> Only these keys (repeatable)
|
|
1315
|
+
--exclude <key|glob> Skip these keys (repeatable)
|
|
1316
|
+
--server <url> Override REDAKTYN_SERVER_URL
|
|
1317
|
+
--org-id <uuid> Override org id (must match the token)
|
|
1318
|
+
--project <name|id> Enrol into this project (hashed under the project's key and
|
|
1319
|
+
compared only with that project's secrets). Default: the
|
|
1320
|
+
organisation's default project.
|
|
1321
|
+
--report-json Machine-readable plan on stdout, no table
|
|
1322
|
+
--no-color Disable ANSI colors
|
|
1323
|
+
|
|
1324
|
+
${c.bold}Environment${c.reset}
|
|
1325
|
+
REDAKTYN_SERVER_URL API base URL
|
|
1326
|
+
REDAKTYN_PASSPHRASE org passphrase (local HMAC only, never sent)
|
|
1327
|
+
REDAKTYN_TOKEN OWNER/ADMIN JWT — an extension token cannot register
|
|
1328
|
+
REDAKTYN_ORG_ID optional; must agree with the token
|
|
1329
|
+
|
|
1330
|
+
${c.bold}Exit codes${c.reset}
|
|
1331
|
+
0 plan printed, or every write succeeded
|
|
1332
|
+
1 one or more writes failed
|
|
1333
|
+
2 usage, auth or parse error
|
|
1334
|
+
`);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
1338
|
+
Main
|
|
1339
|
+
═══════════════════════════════════════════════════════════════ */
|
|
1340
|
+
|
|
1341
|
+
async function loadInput(args) {
|
|
1342
|
+
const modes = [
|
|
1343
|
+
args.env && "env",
|
|
1344
|
+
args.jsonFile && "json",
|
|
1345
|
+
args.yaml && "yaml",
|
|
1346
|
+
args.stdin && "stdin",
|
|
1347
|
+
].filter(Boolean);
|
|
1348
|
+
|
|
1349
|
+
if (modes.length === 0) {
|
|
1350
|
+
throw new UsageError("Nothing to import. Pass one of --env, --json, --yaml or --stdin (see --help).");
|
|
1351
|
+
}
|
|
1352
|
+
if (modes.length > 1) {
|
|
1353
|
+
throw new UsageError(`Pick one input mode, got: ${modes.map((m) => "--" + m).join(", ")}`);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const mode = modes[0];
|
|
1357
|
+
if (mode === "env") {
|
|
1358
|
+
const parsed = parseDotenv(readFileOrDie(args.env, "--env"));
|
|
1359
|
+
return { ...parsed, mode, defaultSource: basename(args.env) };
|
|
1360
|
+
}
|
|
1361
|
+
if (mode === "json") {
|
|
1362
|
+
const parsed = parseJsonSecrets(readFileOrDie(args.jsonFile, "--json"));
|
|
1363
|
+
return { ...parsed, mode, defaultSource: basename(args.jsonFile) };
|
|
1364
|
+
}
|
|
1365
|
+
if (mode === "yaml") {
|
|
1366
|
+
const parsed = parseFlatYaml(readFileOrDie(args.yaml, "--yaml"));
|
|
1367
|
+
return { ...parsed, mode, defaultSource: basename(args.yaml) };
|
|
1368
|
+
}
|
|
1369
|
+
const text = await readStdin();
|
|
1370
|
+
if (text.trim() === "") throw new UsageError("Nothing arrived on stdin.");
|
|
1371
|
+
const parsed = parseJsonSecrets(text);
|
|
1372
|
+
return { ...parsed, mode, defaultSource: "stdin" };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function resolveCredentials(args) {
|
|
1376
|
+
const serverUrl = args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
|
|
1377
|
+
const token = process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"];
|
|
1378
|
+
|
|
1379
|
+
if (!serverUrl) throw new UsageError("Set REDAKTYN_SERVER_URL (or pass --server).");
|
|
1380
|
+
if (!token) {
|
|
1381
|
+
// Named explicitly: REDAKTYN_DEVICE_TOKEN is deliberately not accepted, so
|
|
1382
|
+
// a shell set up for redaktyn-scan does not silently supply a token that
|
|
1383
|
+
// cannot write.
|
|
1384
|
+
throw new UsageError("Set REDAKTYN_TOKEN to an OWNER or ADMIN login token.");
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const payload = decodeJwtPayload(token);
|
|
1388
|
+
const roleProblem = tokenRoleProblem(payload);
|
|
1389
|
+
if (roleProblem) throw new UsageError(roleProblem);
|
|
1390
|
+
|
|
1391
|
+
const orgId = args.orgId || payload?.orgId || payload?.org_id || process.env["REDAKTYN_ORG_ID"];
|
|
1392
|
+
if (!orgId) {
|
|
1393
|
+
throw new UsageError("Could not determine the org id from the token. Pass --org-id.");
|
|
1394
|
+
}
|
|
1395
|
+
const envOrgId = args.orgId || process.env["REDAKTYN_ORG_ID"];
|
|
1396
|
+
if (envOrgId && payload?.orgId && envOrgId !== payload.orgId) {
|
|
1397
|
+
throw new UsageError("The org id given does not match the token's org id.");
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
let passphrase = process.env["REDAKTYN_PASSPHRASE"];
|
|
1401
|
+
if (!passphrase) {
|
|
1402
|
+
if (!process.stdin.isTTY) {
|
|
1403
|
+
throw new UsageError(
|
|
1404
|
+
"Set REDAKTYN_PASSPHRASE. It is used locally to derive the HMAC key and is never sent."
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
passphrase = await promptHidden("Org passphrase:");
|
|
1408
|
+
}
|
|
1409
|
+
if (!passphrase) throw new UsageError("No passphrase given.");
|
|
1410
|
+
|
|
1411
|
+
return { serverUrl, token, orgId, passphrase };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function main() {
|
|
1415
|
+
const args = parseArgs(process.argv);
|
|
1416
|
+
if (args.help) {
|
|
1417
|
+
printHelp();
|
|
1418
|
+
return 0;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const input = await loadInput(args);
|
|
1422
|
+
const source = normalizeSource(args.source ?? input.defaultSource);
|
|
1423
|
+
|
|
1424
|
+
// Last-wins on duplicate keys, matching how a shell sources a dotenv file.
|
|
1425
|
+
const deduped = new Map();
|
|
1426
|
+
for (const e of input.entries) deduped.set(e.key, e);
|
|
1427
|
+
const duplicates = input.entries.length - deduped.size;
|
|
1428
|
+
|
|
1429
|
+
let selected = [...deduped.values()];
|
|
1430
|
+
if (args.only.length > 0) selected = selected.filter((e) => matchesAny(e.key, args.only));
|
|
1431
|
+
if (args.exclude.length > 0) selected = selected.filter((e) => !matchesAny(e.key, args.exclude));
|
|
1432
|
+
|
|
1433
|
+
if (selected.length === 0) {
|
|
1434
|
+
throw new UsageError(
|
|
1435
|
+
`No usable keys found in the input (${input.mode}). ` +
|
|
1436
|
+
(input.problems?.length ? `${input.problems.length} line(s) were skipped — see above.` : "")
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const { serverUrl, token, orgId, passphrase } = await resolveCredentials(args);
|
|
1441
|
+
|
|
1442
|
+
const minLength = args.minLength ?? DEFAULT_MIN_LENGTH;
|
|
1443
|
+
const orgKey = await deriveKey(passphrase, orgId);
|
|
1444
|
+
const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
|
|
1445
|
+
// A project with its own key hashes under HKDF(orgKey, projectId); the default
|
|
1446
|
+
// project carries the org key itself. Filing a digest under the wrong one lists
|
|
1447
|
+
// as ACTIVE and matches nothing, which is why the server also checks.
|
|
1448
|
+
const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
|
|
1449
|
+
|
|
1450
|
+
const candidates = [];
|
|
1451
|
+
const rejected = [];
|
|
1452
|
+
for (const entry of selected) {
|
|
1453
|
+
if (entry.key.length > 200) {
|
|
1454
|
+
// The server caps labels at 200 chars, so this would be a 400 during
|
|
1455
|
+
// apply, after the operator had already approved the plan.
|
|
1456
|
+
rejected.push({ key: entry.key, action: "rejected", reason: "key longer than 200 characters" });
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
const canonical = canonicalize(entry.value);
|
|
1460
|
+
const verdict = junkVerdict(canonical, { minLength });
|
|
1461
|
+
if (!verdict.ok && !args.allowWeak) {
|
|
1462
|
+
rejected.push({ key: entry.key, action: "rejected", reason: verdict.reason, length: canonical.length });
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
const digests = computeHmac(entry.value, key);
|
|
1466
|
+
candidates.push({
|
|
1467
|
+
key: entry.key,
|
|
1468
|
+
digests,
|
|
1469
|
+
weak: !verdict.ok,
|
|
1470
|
+
weakReason: verdict.reason,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
let existing = await fetchExistingSecrets(serverUrl, token);
|
|
1475
|
+
// Compared only with the chosen project's own secrets: the same label in
|
|
1476
|
+
// another project is a different secret under a different key.
|
|
1477
|
+
if (project) existing = existing.filter((s) => s && s.projectId === project.id);
|
|
1478
|
+
// Against a server deployed before the scan prefilter, sending it would 400
|
|
1479
|
+
// every write, and comparing it would re-plan every row as a rotation on
|
|
1480
|
+
// every run. Planned as that server will store it: without.
|
|
1481
|
+
const prefilterSupported = serverStoresScanPrefilter(existing);
|
|
1482
|
+
if (!prefilterSupported) {
|
|
1483
|
+
for (const cand of candidates) delete cand.digests.scanPrefilter;
|
|
1484
|
+
}
|
|
1485
|
+
const { plan, missing } = diffAgainstServer({ candidates, existing, source });
|
|
1486
|
+
|
|
1487
|
+
const byKey = new Map(candidates.map((cd) => [cd.key, cd]));
|
|
1488
|
+
for (const row of plan) {
|
|
1489
|
+
const cand = byKey.get(row.key);
|
|
1490
|
+
if (cand?.weak && (row.action === "create" || row.action === "rotate")) {
|
|
1491
|
+
row.reason = `${row.reason} (weak: ${cand.weakReason}, allowed by --allow-weak)`;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const rows = [...plan, ...rejected].sort(
|
|
1496
|
+
(a, b) => actionRank(a.action) - actionRank(b.action) || a.key.localeCompare(b.key)
|
|
1497
|
+
);
|
|
1498
|
+
|
|
1499
|
+
// Projected budget counts what the org would look like after the writes, since
|
|
1500
|
+
// that is the number that decides whether pastes still get scanned.
|
|
1501
|
+
const projected = projectAfterImport({ existing, plan, byKey });
|
|
1502
|
+
const budget = scanBudgetForSecrets(projected);
|
|
1503
|
+
|
|
1504
|
+
const writes = plan.filter((r) => r.action === "create" || r.action === "rotate");
|
|
1505
|
+
|
|
1506
|
+
if (args.reportJson) {
|
|
1507
|
+
console.log(
|
|
1508
|
+
JSON.stringify(
|
|
1509
|
+
{
|
|
1510
|
+
source,
|
|
1511
|
+
sourceTag: sourceTag(source),
|
|
1512
|
+
mode: input.mode,
|
|
1513
|
+
shape: input.shape ?? null,
|
|
1514
|
+
dryRun: !args.apply,
|
|
1515
|
+
minLength,
|
|
1516
|
+
allowWeak: args.allowWeak,
|
|
1517
|
+
duplicateKeys: duplicates,
|
|
1518
|
+
inputProblems: input.problems ?? [],
|
|
1519
|
+
counts: tally(rows),
|
|
1520
|
+
plan: rows.map((r) => ({
|
|
1521
|
+
key: r.key,
|
|
1522
|
+
action: r.action,
|
|
1523
|
+
reason: r.reason ?? null,
|
|
1524
|
+
length: r.length ?? null,
|
|
1525
|
+
})),
|
|
1526
|
+
missing,
|
|
1527
|
+
scanBudget: {
|
|
1528
|
+
windowLengths: budget.windowLengths,
|
|
1529
|
+
maxPasteChars: budget.ceiling === Infinity ? null : budget.ceiling,
|
|
1530
|
+
warn: budget.ceiling < PASTE_CEILING_WARN_CHARS,
|
|
1531
|
+
atCharacterCap: budget.atCharacterCap,
|
|
1532
|
+
legacySecrets: budget.legacySecrets,
|
|
1533
|
+
maxPasteCharsIfReenrolled:
|
|
1534
|
+
budget.ceilingIfReenrolled === Infinity ? null : budget.ceilingIfReenrolled,
|
|
1535
|
+
serverStoresScanPrefilter: prefilterSupported,
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1538
|
+
null,
|
|
1539
|
+
2
|
|
1540
|
+
)
|
|
1541
|
+
);
|
|
1542
|
+
if (!args.apply) return 0;
|
|
1543
|
+
} else {
|
|
1544
|
+
for (const p of input.problems ?? []) {
|
|
1545
|
+
const where = p.line !== undefined ? `line ${p.line}` : p.key;
|
|
1546
|
+
console.log(`${c.yellow}!${c.reset} ${where}: ${p.message}`);
|
|
1547
|
+
}
|
|
1548
|
+
if (duplicates > 0) {
|
|
1549
|
+
console.log(`${c.dim}${duplicates} duplicate key(s) in the input; the last value of each was used.${c.reset}`);
|
|
1550
|
+
}
|
|
1551
|
+
if (!prefilterSupported) printOlderServerNotice();
|
|
1552
|
+
printPlan({ rows, missing, source, dryRun: !args.apply, budget, projectName: project ? project.name : null });
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (!args.apply) return 0;
|
|
1556
|
+
|
|
1557
|
+
if (writes.length === 0) {
|
|
1558
|
+
console.log(`${c.dim}Nothing to write.${c.reset}`);
|
|
1559
|
+
return 0;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (!args.yes) {
|
|
1563
|
+
const counts = tally(plan);
|
|
1564
|
+
const ok = await confirm(
|
|
1565
|
+
`Register ${counts.create} new and rotate ${counts.rotate} existing fingerprint(s) for this org? [y/N]`
|
|
1566
|
+
);
|
|
1567
|
+
if (!ok) {
|
|
1568
|
+
console.log("Aborted.");
|
|
1569
|
+
return 0;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
console.log("");
|
|
1574
|
+
const results = await applyPlan({ rows: writes, byKey, serverUrl, token, source, projectId: project ? project.id : null });
|
|
1575
|
+
const failed = results.filter((r) => !r.ok);
|
|
1576
|
+
|
|
1577
|
+
console.log("");
|
|
1578
|
+
console.log(
|
|
1579
|
+
failed.length === 0
|
|
1580
|
+
? `${c.green}${results.length} fingerprint(s) registered.${c.reset}`
|
|
1581
|
+
: `${c.red}${failed.length} of ${results.length} failed.${c.reset}`
|
|
1582
|
+
);
|
|
1583
|
+
printBudget(budget);
|
|
1584
|
+
console.log("");
|
|
1585
|
+
|
|
1586
|
+
return failed.length === 0 ? 0 : 1;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const ACTION_ORDER = ["create", "rotate", "conflict", "skip", "rejected"];
|
|
1590
|
+
const actionRank = (a) => {
|
|
1591
|
+
const i = ACTION_ORDER.indexOf(a);
|
|
1592
|
+
return i === -1 ? ACTION_ORDER.length : i;
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* The secret list as it would be after the plan is applied, for the budget
|
|
1597
|
+
* projection. Rotations replace lengths rather than adding them, which matters:
|
|
1598
|
+
* re-importing the same file must not look like it doubles the scan cost.
|
|
1599
|
+
*/
|
|
1600
|
+
function projectAfterImport({ existing, plan, byKey }) {
|
|
1601
|
+
const out = existing.map((s) => ({ ...s }));
|
|
1602
|
+
const byId = new Map(out.map((s) => [s.id, s]));
|
|
1603
|
+
|
|
1604
|
+
for (const row of plan) {
|
|
1605
|
+
const cand = byKey.get(row.key);
|
|
1606
|
+
if (!cand) continue;
|
|
1607
|
+
const d = cand.digests;
|
|
1608
|
+
if (row.action === "create") {
|
|
1609
|
+
out.push({
|
|
1610
|
+
id: `pending:${row.key}`,
|
|
1611
|
+
label: row.key,
|
|
1612
|
+
hmacHex: d.digest,
|
|
1613
|
+
length: d.length,
|
|
1614
|
+
hmacHexStripped: d.hmacHexStripped,
|
|
1615
|
+
lengthStripped: d.lengthStripped,
|
|
1616
|
+
hmacHexLower: d.hmacHexLower,
|
|
1617
|
+
lengthLower: d.lengthLower,
|
|
1618
|
+
scanPrefilter: d.scanPrefilter,
|
|
1619
|
+
tags: [],
|
|
1620
|
+
});
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
if (row.action === "rotate") {
|
|
1624
|
+
const target = byId.get(row.id);
|
|
1625
|
+
if (!target) continue;
|
|
1626
|
+
target.hmacHex = d.digest;
|
|
1627
|
+
target.length = d.length;
|
|
1628
|
+
target.hmacHexStripped = d.hmacHexStripped;
|
|
1629
|
+
target.lengthStripped = d.lengthStripped;
|
|
1630
|
+
target.hmacHexLower = d.hmacHexLower;
|
|
1631
|
+
target.lengthLower = d.lengthLower;
|
|
1632
|
+
target.scanPrefilter = d.scanPrefilter;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return out;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const invokedDirectly =
|
|
1639
|
+
!!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1640
|
+
|
|
1641
|
+
if (invokedDirectly) {
|
|
1642
|
+
main()
|
|
1643
|
+
.then((code) => process.exit(code))
|
|
1644
|
+
.catch((err) => {
|
|
1645
|
+
if (err instanceof UsageError) {
|
|
1646
|
+
console.error(`${c.red}${err.message}${c.reset}`);
|
|
1647
|
+
process.exit(2);
|
|
1648
|
+
}
|
|
1649
|
+
console.error(`${c.red}Fatal: ${err instanceof Error ? err.message : err}${c.reset}`);
|
|
1650
|
+
process.exit(1);
|
|
1651
|
+
});
|
|
1652
|
+
}
|