Save a redacted .env template alongside pushed secrets
All checks were successful
Docker / build-and-push (push) Successful in 1m51s
All checks were successful
Docker / build-and-push (push) Successful in 1m51s
keep push now saves a comment/structure-preserving, value-redacted copy of the source .env next to the encrypted secrets, so context like "why this exists" or "get this from X" survives ingest instead of being silently dropped. keep pull --template retrieves it. Old vaults pushed before this existed keep working (flat payload, no template) and fail with a clear error rather than crashing if --template is requested.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import { requireIdentityAndRecipientId } from '../identityStore.js';
|
||||
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
|
||||
import { parseEnv, serializeEnv } from '../envFormat.js';
|
||||
import { parseEnv, serializeEnv, buildEnvTemplate } from '../envFormat.js';
|
||||
import {
|
||||
ready,
|
||||
Identity,
|
||||
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
|
||||
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
|
||||
const values = parseEnv(fs.readFileSync(file, 'utf8'));
|
||||
const payload = enc.fromUtf8(JSON.stringify(values));
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const values = parseEnv(raw);
|
||||
const template = buildEnvTemplate(raw);
|
||||
// __keepEnvelope marks the new wrapped shape so vaultPull can tell it
|
||||
// apart from an old vault pushed before templates existed, which is
|
||||
// just the flat { KEY: value } object with no wrapper at all.
|
||||
const payload = enc.fromUtf8(JSON.stringify({ __keepEnvelope: true, values, template }));
|
||||
|
||||
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
|
||||
|
||||
@@ -55,7 +60,7 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
|
||||
console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`);
|
||||
}
|
||||
|
||||
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise<void> {
|
||||
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise<void> {
|
||||
await ready();
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
|
||||
@@ -66,7 +71,23 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil
|
||||
if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale');
|
||||
|
||||
const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext));
|
||||
const values = JSON.parse(enc.toUtf8(plaintext)) as Record<string, string>;
|
||||
const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record<string, unknown>;
|
||||
|
||||
// Vaults pushed before templates existed are just the flat { KEY: value }
|
||||
// object with no wrapper — __keepEnvelope tells the two shapes apart.
|
||||
const values = (parsed.__keepEnvelope ? parsed.values : parsed) as Record<string, string>;
|
||||
const template = parsed.__keepEnvelope ? (parsed.template as string | undefined) : undefined;
|
||||
|
||||
if (wantTemplate) {
|
||||
if (template === undefined) throw new Error(`vault '${vaultKey}' has no saved template (pushed before template support, or the source file had no comments to preserve)`);
|
||||
if (outFile) {
|
||||
fs.writeFileSync(outFile, template);
|
||||
console.error(`wrote template to ${outFile}${previous ? ' (previous version)' : ''}`);
|
||||
} else {
|
||||
process.stdout.write(template);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
|
||||
if (outFile) {
|
||||
|
||||
@@ -20,3 +20,21 @@ export function serializeEnv(values: Record<string, string>): string {
|
||||
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Redacts values while keeping comments, blank lines, and key order intact
|
||||
// — the whole point is to preserve the "why"/"where to get this" context
|
||||
// that plain parseEnv() throws away, without keeping the actual secret
|
||||
// around in the template.
|
||||
export function buildEnvTemplate(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map(rawLine => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) return line;
|
||||
return `${line.slice(0, eq)}=`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ async function main(): Promise<void> {
|
||||
if (cmd === 'overview') return await adminOverview();
|
||||
|
||||
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
|
||||
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
|
||||
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'), boolFlag(rest, 'template'));
|
||||
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
||||
if (cmd === 'log') return await vaultLog(sub);
|
||||
@@ -54,7 +54,8 @@ function printUsage(): void {
|
||||
keep identity set-id <recipient-id>
|
||||
|
||||
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
|
||||
keep pull <vault> [--format env|json] [--out <path>] [--previous]
|
||||
keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template]
|
||||
(--template: comments/structure with values redacted, not the secrets)
|
||||
keep grant <vault> <recipient-id> [--read-only]
|
||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||
keep log <vault>
|
||||
|
||||
Reference in New Issue
Block a user