@joinremba/beacon 0.1.0 → 0.4.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 +121 -12
- package/package.json +2 -1
- package/src/cli-config.ts +15 -49
- package/src/cli.test.ts +51 -1
- package/src/cli.ts +407 -22
- package/src/encryption.test.ts +24 -0
- package/src/encryption.ts +59 -0
- package/src/index.test.ts +190 -30
- package/src/index.ts +95 -71
- package/src/schema.ts +56 -0
- package/src/types.ts +19 -1
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { loadConfig, type BeaconConfigFile } from "./cli-config";
|
|
4
4
|
import { generateEnvExample } from "./cli-config";
|
|
5
5
|
import { runCheck } from "./cli-config";
|
|
6
|
+
import { encryptEnv, decryptEnv } from "./encryption";
|
|
6
7
|
import { color, icon, formatCheckResult, formatSummary, suggestKeys } from "./cli-format";
|
|
7
8
|
|
|
8
9
|
interface ParsedArgs {
|
|
@@ -10,6 +11,9 @@ interface ParsedArgs {
|
|
|
10
11
|
configPath: string;
|
|
11
12
|
output: string;
|
|
12
13
|
profile?: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
input?: string;
|
|
16
|
+
allProfiles: boolean;
|
|
13
17
|
wantsHelp: boolean;
|
|
14
18
|
helpTopic: string;
|
|
15
19
|
}
|
|
@@ -18,9 +22,10 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
18
22
|
const args: ParsedArgs = {
|
|
19
23
|
command: "",
|
|
20
24
|
configPath: "",
|
|
21
|
-
output: "
|
|
25
|
+
output: "",
|
|
22
26
|
wantsHelp: false,
|
|
23
27
|
helpTopic: "",
|
|
28
|
+
allProfiles: false,
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
let i = 0;
|
|
@@ -49,6 +54,14 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
49
54
|
} else if (arg === "--profile") {
|
|
50
55
|
i++;
|
|
51
56
|
args.profile = argv[i] ?? undefined;
|
|
57
|
+
} else if (arg === "--all-profiles") {
|
|
58
|
+
args.allProfiles = true;
|
|
59
|
+
} else if (arg === "--key") {
|
|
60
|
+
i++;
|
|
61
|
+
args.key = argv[i] ?? undefined;
|
|
62
|
+
} else if (arg === "-i" || arg === "--input") {
|
|
63
|
+
i++;
|
|
64
|
+
args.input = argv[i] ?? ".env";
|
|
52
65
|
} else if (arg === "--help" || arg === "-h") {
|
|
53
66
|
args.wantsHelp = true;
|
|
54
67
|
} else if (args.command === "" && !arg.startsWith("-") && !args.wantsHelp) {
|
|
@@ -71,6 +84,13 @@ async function main() {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
if (args.command === "") {
|
|
87
|
+
if (args.configPath || args.profile || args.key || args.input || args.allProfiles) {
|
|
88
|
+
console.error(
|
|
89
|
+
` ${icon.fail} Missing command. Use ${color.cyan("beacon <command> [options]")}`
|
|
90
|
+
);
|
|
91
|
+
console.error(` Run ${color.cyan("beacon --help")} to see available commands`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
74
94
|
printHelp("");
|
|
75
95
|
return;
|
|
76
96
|
}
|
|
@@ -82,6 +102,21 @@ async function main() {
|
|
|
82
102
|
case "check":
|
|
83
103
|
await handleCheck(args);
|
|
84
104
|
break;
|
|
105
|
+
case "encrypt":
|
|
106
|
+
await handleEncrypt(args);
|
|
107
|
+
break;
|
|
108
|
+
case "decrypt":
|
|
109
|
+
await handleDecrypt(args);
|
|
110
|
+
break;
|
|
111
|
+
case "rotate":
|
|
112
|
+
handleRotate(args);
|
|
113
|
+
return;
|
|
114
|
+
case "drift":
|
|
115
|
+
await handleDrift(args);
|
|
116
|
+
break;
|
|
117
|
+
case "docker":
|
|
118
|
+
await handleDockerCheck(args);
|
|
119
|
+
break;
|
|
85
120
|
default:
|
|
86
121
|
console.error(` ${icon.fail} Unknown command: ${color.bold(args.command)}`);
|
|
87
122
|
printHelp("");
|
|
@@ -94,25 +129,61 @@ async function handleInit(args: ParsedArgs) {
|
|
|
94
129
|
|
|
95
130
|
try {
|
|
96
131
|
config = await loadConfig(args.configPath || undefined);
|
|
97
|
-
} catch {
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (args.configPath) {
|
|
134
|
+
console.error(` ${icon.fail} ${(err as Error).message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
98
137
|
config = { schema: {} };
|
|
138
|
+
console.warn(` ${icon.info} No config file found. Creating ${color.bold(".beaconrc.json")}...`);
|
|
139
|
+
const template = {
|
|
140
|
+
schema: {
|
|
141
|
+
DATABASE_URL: { type: "url", required: true, description: "PostgreSQL connection string" },
|
|
142
|
+
PORT: { type: "port", default: 3000, description: "HTTP server port" },
|
|
143
|
+
},
|
|
144
|
+
profiles: {
|
|
145
|
+
production: {
|
|
146
|
+
DB_HOST: { type: "host", required: true, description: "Production DB hostname" },
|
|
147
|
+
},
|
|
148
|
+
staging: {
|
|
149
|
+
DB_HOST: { type: "host", required: true, description: "Staging DB hostname" },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
await Bun.write(".beaconrc.json", JSON.stringify(template, null, 2) + "\n");
|
|
154
|
+
console.log(` ${icon.pass} Created ${color.bold(".beaconrc.json")}`);
|
|
155
|
+
console.log(` Edit it to define your schema and profiles, then run:`);
|
|
156
|
+
console.log(
|
|
157
|
+
` ${color.cyan("bunx beacon init")} ${color.grey("# default profile")}`
|
|
158
|
+
);
|
|
159
|
+
console.log(
|
|
160
|
+
` ${color.cyan("bunx beacon init --profile production")} ${color.grey("# production")}`
|
|
161
|
+
);
|
|
162
|
+
console.log(
|
|
163
|
+
` ${color.cyan("bunx beacon init --all-profiles")} ${color.grey("# all profiles")}`
|
|
164
|
+
);
|
|
165
|
+
process.exit(0);
|
|
99
166
|
}
|
|
100
167
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
168
|
+
const profilesToGenerate = args.allProfiles
|
|
169
|
+
? [undefined, ...Object.keys(config.profiles ?? {})]
|
|
170
|
+
: [args.profile];
|
|
171
|
+
|
|
172
|
+
for (const profile of profilesToGenerate) {
|
|
173
|
+
const output = profile
|
|
174
|
+
? args.output || `.env.example.${profile}`
|
|
175
|
+
: args.output || ".env.example";
|
|
176
|
+
const example = generateEnvExample(config, profile);
|
|
177
|
+
await Bun.write(output, example);
|
|
178
|
+
console.log(` ${icon.pass} Generated ${color.bold(output)}`);
|
|
179
|
+
if (profile) {
|
|
180
|
+
console.log(` Profile: ${color.cyan(profile)}`);
|
|
181
|
+
}
|
|
107
182
|
}
|
|
108
183
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
: config.schema
|
|
113
|
-
).length;
|
|
114
|
-
if (count > 0) {
|
|
115
|
-
console.log(` ${count} variable(s) documented`);
|
|
184
|
+
const schemaKeys = Object.keys(config.schema).length;
|
|
185
|
+
if (schemaKeys > 0) {
|
|
186
|
+
console.log(` ${schemaKeys} variable(s) documented`);
|
|
116
187
|
}
|
|
117
188
|
process.exit(0);
|
|
118
189
|
}
|
|
@@ -159,6 +230,204 @@ async function handleCheck(args: ParsedArgs) {
|
|
|
159
230
|
process.exit(0);
|
|
160
231
|
}
|
|
161
232
|
|
|
233
|
+
async function handleEncrypt(args: ParsedArgs) {
|
|
234
|
+
const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
|
|
235
|
+
if (!encryptionKey) {
|
|
236
|
+
console.error(
|
|
237
|
+
` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
|
|
238
|
+
);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const inputPath = args.input ?? ".env";
|
|
243
|
+
const file = Bun.file(inputPath);
|
|
244
|
+
const exists = await file.exists();
|
|
245
|
+
if (!exists) {
|
|
246
|
+
console.error(` ${icon.fail} Input file not found: ${inputPath}`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const content = await file.text();
|
|
251
|
+
const encrypted = await encryptEnv(content, encryptionKey);
|
|
252
|
+
const outputPath = args.output;
|
|
253
|
+
await Bun.write(outputPath, encrypted);
|
|
254
|
+
console.log(` ${icon.pass} Encrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function handleDecrypt(args: ParsedArgs) {
|
|
259
|
+
const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
|
|
260
|
+
if (!encryptionKey) {
|
|
261
|
+
console.error(
|
|
262
|
+
` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const inputPath = args.input ?? ".env.encrypted";
|
|
268
|
+
const file = Bun.file(inputPath);
|
|
269
|
+
const exists = await file.exists();
|
|
270
|
+
if (!exists) {
|
|
271
|
+
console.error(` ${icon.fail} Encrypted file not found: ${inputPath}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const encryptedBase64 = (await file.text()).trim();
|
|
276
|
+
const decrypted = await decryptEnv(encryptedBase64, encryptionKey);
|
|
277
|
+
const outputPath = args.output;
|
|
278
|
+
await Bun.write(outputPath, decrypted);
|
|
279
|
+
console.log(` ${icon.pass} Decrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function handleRotate(_args: ParsedArgs) {
|
|
284
|
+
console.log(`
|
|
285
|
+
${color.bold("Secret Rotation Checklist")}
|
|
286
|
+
|
|
287
|
+
Follow these steps to rotate a secret safely:
|
|
288
|
+
|
|
289
|
+
${color.bold("1.")} Generate a new secret value
|
|
290
|
+
Use: openssl rand -base64 32
|
|
291
|
+
|
|
292
|
+
${color.bold("2.")} Deploy the new secret alongside the old one
|
|
293
|
+
Add the new value to your env/secret store (e.g. DATABASE_URL_NEW)
|
|
294
|
+
Your app should accept both old and new values during the transition
|
|
295
|
+
|
|
296
|
+
${color.bold("3.")} Update all consumers to use the new secret
|
|
297
|
+
Deploy config changes pointing to the new secret
|
|
298
|
+
|
|
299
|
+
${color.bold("4.")} Verify everything works
|
|
300
|
+
Run: ${color.cyan("bunx beacon check")}
|
|
301
|
+
Check logs, metrics, and error rates
|
|
302
|
+
|
|
303
|
+
${color.bold("5.")} Revoke the old secret
|
|
304
|
+
Remove the old value from your env/secret store
|
|
305
|
+
Rotate any credentials in external services
|
|
306
|
+
|
|
307
|
+
${color.bold("6.")} Audit
|
|
308
|
+
Confirm no services still reference the old secret
|
|
309
|
+
Update docs and secrets inventory
|
|
310
|
+
|
|
311
|
+
${color.dim("Pro tip: Use beacon encrypt to store secrets safely in git.")}
|
|
312
|
+
${color.dim(" Set BEACON_ENCRYPTION_KEY in your deployment env.")}
|
|
313
|
+
`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function handleDrift(args: ParsedArgs) {
|
|
317
|
+
let config: BeaconConfigFile;
|
|
318
|
+
try {
|
|
319
|
+
config = await loadConfig(args.configPath || undefined);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(` ${icon.fail} ${(err as Error).message}`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const profile = args.profile;
|
|
326
|
+
const mergedSchema = { ...config.schema };
|
|
327
|
+
if (profile && config.profiles?.[profile]) {
|
|
328
|
+
Object.assign(mergedSchema, config.profiles[profile]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const driftResults: Array<{ key: string; expected: string; actual: string }> = [];
|
|
332
|
+
|
|
333
|
+
for (const [key, entry] of Object.entries(mergedSchema)) {
|
|
334
|
+
const raw = process.env[key];
|
|
335
|
+
const isField = "type" in entry;
|
|
336
|
+
const required = isField ? (entry as { required?: boolean }).required !== false : true;
|
|
337
|
+
|
|
338
|
+
if (raw === undefined || raw === "") {
|
|
339
|
+
if (required) {
|
|
340
|
+
driftResults.push({ key, expected: "set", actual: "not set" });
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (isField) {
|
|
346
|
+
const field = entry as { type?: string; values?: readonly string[] };
|
|
347
|
+
if (field.type === "enum" && field.values && !field.values.includes(raw)) {
|
|
348
|
+
driftResults.push({
|
|
349
|
+
key,
|
|
350
|
+
expected: `one of: ${field.values.join(" | ")}`,
|
|
351
|
+
actual: raw,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (driftResults.length === 0) {
|
|
358
|
+
console.log(` ${icon.pass} ${color.bold("No config drift detected")}`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log(` ${icon.fail} ${color.bold("Config drift detected:")}\n`);
|
|
363
|
+
for (const d of driftResults) {
|
|
364
|
+
console.log(` ${color.yellow("⚠")} ${color.bold(d.key)}`);
|
|
365
|
+
console.log(` Expected: ${color.cyan(d.expected)}`);
|
|
366
|
+
console.log(` Actual: ${color.red(d.actual)}`);
|
|
367
|
+
console.log();
|
|
368
|
+
}
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function handleDockerCheck(args: ParsedArgs) {
|
|
373
|
+
const isDocker = await Bun.file("/.dockerenv")
|
|
374
|
+
.exists()
|
|
375
|
+
.catch(() => false);
|
|
376
|
+
const isK8s = process.env.KUBERNETES_SERVICE_HOST !== undefined;
|
|
377
|
+
|
|
378
|
+
console.log(` ${icon.info} ${color.bold("Environment Detection")}\n`);
|
|
379
|
+
|
|
380
|
+
if (isDocker) {
|
|
381
|
+
console.log(` ${icon.pass} Running inside Docker container`);
|
|
382
|
+
} else {
|
|
383
|
+
console.log(` ${color.dim("○")} Not detected as Docker`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (isK8s) {
|
|
387
|
+
console.log(` ${icon.pass} Running inside Kubernetes pod`);
|
|
388
|
+
console.log(
|
|
389
|
+
` KUBERNETES_SERVICE_HOST=${color.cyan(process.env.KUBERNETES_SERVICE_HOST ?? "")}`
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
console.log(` ${color.dim("○")} Not detected as Kubernetes`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log();
|
|
396
|
+
|
|
397
|
+
const dockerVars = ["DOCKER_HOST", "CONTAINER_NAME", "COMPOSE_PROJECT_NAME"];
|
|
398
|
+
const k8sVars = ["KUBERNETES_SERVICE_HOST", "KUBERNETES_SERVICE_PORT"];
|
|
399
|
+
|
|
400
|
+
if (isDocker || isK8s) {
|
|
401
|
+
console.log(` ${icon.info} ${color.bold("Container Env Vars Found")}\n`);
|
|
402
|
+
const relevant = [...(isDocker ? dockerVars : []), ...(isK8s ? k8sVars : [])];
|
|
403
|
+
for (const v of relevant) {
|
|
404
|
+
if (process.env[v]) {
|
|
405
|
+
console.log(` ${color.green("✓")} ${v}=${color.dim(process.env[v] ?? "")}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
console.log();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let config: BeaconConfigFile | undefined;
|
|
412
|
+
try {
|
|
413
|
+
config = await loadConfig(args.configPath || undefined);
|
|
414
|
+
} catch {
|
|
415
|
+
// no config file, just show env info
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (config) {
|
|
419
|
+
console.log(` ${icon.info} ${color.bold("Running beacon check against schema")}\n`);
|
|
420
|
+
const result = await runCheck(config, args.profile);
|
|
421
|
+
process.stdout.write(formatCheckResult(result.results));
|
|
422
|
+
const passed = result.results.filter((r) => r.status === "ok").length;
|
|
423
|
+
const failed = result.results.filter((r) => r.status !== "ok").length;
|
|
424
|
+
process.stdout.write(formatSummary(passed, failed));
|
|
425
|
+
if (failed > 0) process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
process.exit(0);
|
|
429
|
+
}
|
|
430
|
+
|
|
162
431
|
function printHelp(command: string) {
|
|
163
432
|
if (command === "init") {
|
|
164
433
|
console.log(`
|
|
@@ -172,19 +441,130 @@ ${color.bold("DESCRIPTION")}
|
|
|
172
441
|
|
|
173
442
|
${color.bold("OPTIONS")}
|
|
174
443
|
-c, --config <path> Path to config file
|
|
175
|
-
|
|
444
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
176
445
|
-o, --output <path> Output file for init
|
|
177
|
-
|
|
178
|
-
--profile <name>
|
|
446
|
+
${color.dim("(default: .env.example or .env.example.<profile>)")}
|
|
447
|
+
--profile <name> Generate for a specific profile
|
|
448
|
+
--all-profiles Generate .env.example for every profile
|
|
179
449
|
|
|
180
450
|
${color.bold("EXAMPLES")}
|
|
181
451
|
beacon init ${color.grey("# generate .env.example")}
|
|
182
|
-
beacon init --profile production ${color.grey("#
|
|
452
|
+
beacon init --profile production ${color.grey("# generate .env.example.production")}
|
|
453
|
+
beacon init --all-profiles ${color.grey("# generate for all profiles")}
|
|
183
454
|
beacon init -c ./config/beacon.json ${color.grey("# custom config path")}
|
|
184
455
|
`);
|
|
185
456
|
return;
|
|
186
457
|
}
|
|
187
458
|
|
|
459
|
+
if (command === "rotate") {
|
|
460
|
+
console.log(`
|
|
461
|
+
${color.bold("USAGE")}
|
|
462
|
+
beacon rotate
|
|
463
|
+
|
|
464
|
+
${color.bold("DESCRIPTION")}
|
|
465
|
+
Print a step-by-step secret rotation checklist.
|
|
466
|
+
Follow it to rotate any secret (DB credentials, API keys, etc.).
|
|
467
|
+
|
|
468
|
+
${color.bold("EXAMPLES")}
|
|
469
|
+
beacon rotate
|
|
470
|
+
`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (command === "drift") {
|
|
475
|
+
console.log(`
|
|
476
|
+
${color.bold("USAGE")}
|
|
477
|
+
beacon drift [options]
|
|
478
|
+
|
|
479
|
+
${color.bold("DESCRIPTION")}
|
|
480
|
+
Detect config drift — compares your actual environment against
|
|
481
|
+
the schema defined in your beacon config file.
|
|
482
|
+
Reports missing variables, type mismatches, and unexpected values.
|
|
483
|
+
|
|
484
|
+
${color.bold("OPTIONS")}
|
|
485
|
+
-c, --config <path> Path to config file
|
|
486
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
487
|
+
--profile <name> Profile to merge (staging, production, etc.)
|
|
488
|
+
|
|
489
|
+
${color.bold("EXAMPLES")}
|
|
490
|
+
beacon drift
|
|
491
|
+
beacon drift --profile production
|
|
492
|
+
`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (command === "docker") {
|
|
497
|
+
console.log(`
|
|
498
|
+
${color.bold("USAGE")}
|
|
499
|
+
beacon docker [options]
|
|
500
|
+
|
|
501
|
+
${color.bold("DESCRIPTION")}
|
|
502
|
+
Validate the environment in Docker and Kubernetes contexts.
|
|
503
|
+
Detects the container runtime, checks common container env vars,
|
|
504
|
+
and runs a full beacon check against your schema.
|
|
505
|
+
|
|
506
|
+
${color.bold("OPTIONS")}
|
|
507
|
+
-c, --config <path> Path to config file
|
|
508
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
509
|
+
--profile <name> Profile to merge
|
|
510
|
+
|
|
511
|
+
${color.bold("EXAMPLES")}
|
|
512
|
+
beacon docker
|
|
513
|
+
beacon docker --profile staging
|
|
514
|
+
`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (command === "encrypt") {
|
|
519
|
+
console.log(`
|
|
520
|
+
${color.bold("USAGE")}
|
|
521
|
+
beacon encrypt [options]
|
|
522
|
+
|
|
523
|
+
${color.bold("DESCRIPTION")}
|
|
524
|
+
Encrypt a .env file so secrets can be committed safely.
|
|
525
|
+
Requires a 256-bit encryption key (passed via --key or BEACON_ENCRYPTION_KEY).
|
|
526
|
+
|
|
527
|
+
${color.bold("OPTIONS")}
|
|
528
|
+
-i, --input <path> Input .env file
|
|
529
|
+
${color.dim("(default: .env)")}
|
|
530
|
+
-o, --output <path> Output encrypted file
|
|
531
|
+
${color.dim("(default: .env.encrypted)")}
|
|
532
|
+
--key <key> Encryption key
|
|
533
|
+
${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
|
|
534
|
+
|
|
535
|
+
${color.bold("EXAMPLES")}
|
|
536
|
+
beacon encrypt ${color.grey("# encrypt .env → .env.encrypted")}
|
|
537
|
+
beacon encrypt -i .env.prod -o .env.prod.encrypted ${color.grey("# custom paths")}
|
|
538
|
+
BEACON_ENCRYPTION_KEY=... beacon encrypt ${color.grey("# key from env")}
|
|
539
|
+
`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (command === "decrypt") {
|
|
544
|
+
console.log(`
|
|
545
|
+
${color.bold("USAGE")}
|
|
546
|
+
beacon decrypt [options]
|
|
547
|
+
|
|
548
|
+
${color.bold("DESCRIPTION")}
|
|
549
|
+
Decrypt a .env.encrypted file back to plaintext.
|
|
550
|
+
Requires the same key used during encryption.
|
|
551
|
+
|
|
552
|
+
${color.bold("OPTIONS")}
|
|
553
|
+
-i, --input <path> Input encrypted file
|
|
554
|
+
${color.dim("(default: .env.encrypted)")}
|
|
555
|
+
-o, --output <path> Output .env file
|
|
556
|
+
${color.dim("(default: .env)")}
|
|
557
|
+
--key <key> Encryption key
|
|
558
|
+
${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
|
|
559
|
+
|
|
560
|
+
${color.bold("EXAMPLES")}
|
|
561
|
+
beacon decrypt ${color.grey("# decrypt .env.encrypted → .env")}
|
|
562
|
+
beacon decrypt -i .env.prod.encrypted -o .env.prod ${color.grey("# custom paths")}
|
|
563
|
+
BEACON_ENCRYPTION_KEY=... beacon decrypt ${color.grey("# key from env")}
|
|
564
|
+
`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
188
568
|
if (command === "check") {
|
|
189
569
|
console.log(`
|
|
190
570
|
${color.bold("USAGE")}
|
|
@@ -218,9 +598,14 @@ ${color.bold("USAGE")}
|
|
|
218
598
|
beacon <command> [options]
|
|
219
599
|
|
|
220
600
|
${color.bold("COMMANDS")}
|
|
221
|
-
init
|
|
222
|
-
check
|
|
223
|
-
|
|
601
|
+
init ${color.dim("Generate .env.example from your config")}
|
|
602
|
+
check ${color.dim("Validate current environment against your schema")}
|
|
603
|
+
encrypt ${color.dim("Encrypt .env file for safe committing")}
|
|
604
|
+
decrypt ${color.dim("Decrypt .env.encrypted back to plaintext")}
|
|
605
|
+
rotate ${color.dim("Print secret rotation checklist")}
|
|
606
|
+
drift ${color.dim("Detect config drift between schema and env")}
|
|
607
|
+
docker ${color.dim("Validate env in Docker/Kubernetes contexts")}
|
|
608
|
+
help ${color.dim("Show help for a specific command")}
|
|
224
609
|
|
|
225
610
|
${color.bold("OPTIONS")}
|
|
226
611
|
-c, --config <path> Path to config file (default: .beaconrc.json or beacon.config.json)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { encryptEnv, decryptEnv } from "./encryption";
|
|
3
|
+
|
|
4
|
+
test("encrypts and decrypts env content", async () => {
|
|
5
|
+
const key = "test-encryption-key-32bytes!";
|
|
6
|
+
const envContent = `DATABASE_URL=postgres://localhost/mydb
|
|
7
|
+
API_KEY=supersecret123
|
|
8
|
+
PORT=3000`;
|
|
9
|
+
|
|
10
|
+
const encrypted = await encryptEnv(envContent, key);
|
|
11
|
+
expect(typeof encrypted).toBe("string");
|
|
12
|
+
expect(encrypted.length).toBeGreaterThan(0);
|
|
13
|
+
|
|
14
|
+
const decrypted = await decryptEnv(encrypted, key);
|
|
15
|
+
expect(decrypted).toBe(envContent);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rejects wrong key", async () => {
|
|
19
|
+
const key = "correct-key-32bytes!!!!!";
|
|
20
|
+
const envContent = "SECRET=value";
|
|
21
|
+
const encrypted = await encryptEnv(envContent, key);
|
|
22
|
+
const result = await decryptEnv(encrypted, "wrong-key-32bytes!!!!!!").catch(() => "error");
|
|
23
|
+
expect(result).toBe("error");
|
|
24
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const ALGORITHM = "AES-GCM";
|
|
2
|
+
const IV_LENGTH = 12;
|
|
3
|
+
const TAG_LENGTH = 128;
|
|
4
|
+
|
|
5
|
+
async function deriveKey(password: string): Promise<CryptoKey> {
|
|
6
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
7
|
+
"raw",
|
|
8
|
+
new TextEncoder().encode(password),
|
|
9
|
+
"HKDF",
|
|
10
|
+
false,
|
|
11
|
+
["deriveKey"]
|
|
12
|
+
);
|
|
13
|
+
return crypto.subtle.deriveKey(
|
|
14
|
+
{
|
|
15
|
+
name: "HKDF",
|
|
16
|
+
hash: "SHA-256",
|
|
17
|
+
salt: new Uint8Array(32),
|
|
18
|
+
info: new TextEncoder().encode("beacon-env-encryption-v1"),
|
|
19
|
+
},
|
|
20
|
+
keyMaterial,
|
|
21
|
+
{ name: ALGORITHM, length: 256 },
|
|
22
|
+
false,
|
|
23
|
+
["encrypt", "decrypt"]
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function encryptEnv(envContent: string, key: string): Promise<string> {
|
|
28
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
29
|
+
const cryptoKey = await deriveKey(key);
|
|
30
|
+
const data = new TextEncoder().encode(envContent);
|
|
31
|
+
|
|
32
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
33
|
+
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
|
34
|
+
cryptoKey,
|
|
35
|
+
data
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const combined = new Uint8Array(IV_LENGTH + encrypted.byteLength);
|
|
39
|
+
combined.set(iv, 0);
|
|
40
|
+
combined.set(new Uint8Array(encrypted), IV_LENGTH);
|
|
41
|
+
|
|
42
|
+
return Buffer.from(combined).toString("base64");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function decryptEnv(encryptedBase64: string, key: string): Promise<string> {
|
|
46
|
+
const combined = Buffer.from(encryptedBase64, "base64");
|
|
47
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
48
|
+
const data = combined.slice(IV_LENGTH);
|
|
49
|
+
|
|
50
|
+
const cryptoKey = await deriveKey(key);
|
|
51
|
+
|
|
52
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
53
|
+
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
|
54
|
+
cryptoKey,
|
|
55
|
+
data
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return new TextDecoder().decode(decrypted);
|
|
59
|
+
}
|