From 8dcb675ad25f2c8d35a0fe728a4a0130e3feb06f Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 15 Jul 2026 20:41:45 +0200 Subject: [PATCH] Save a redacted .env template alongside pushed secrets 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. --- README.md | 17 +++++++++++++++++ src/cli/commands/vault.ts | 31 ++++++++++++++++++++++++++----- src/cli/envFormat.ts | 18 ++++++++++++++++++ src/cli/index.ts | 5 +++-- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b6fec48..e608f82 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,23 @@ keep push myapp/production --file .env.production keep pull myapp/production > .env ``` +## Templates + +`keep push` also saves a redacted copy of the source `.env` alongside +the secrets — same keys, comments, and blank lines, values stripped. +Comments explaining *where a value comes from* or *why it exists* are +exactly the kind of context a bare key/value pair throws away, so this +keeps it without keeping the secret itself around unencrypted anywhere. + +```bash +keep pull myapp/production --template > .env.example +``` + +Vaults pushed before this existed just don't have a saved template — +`keep pull --template` on one of those fails with a clear error instead +of silently returning nothing. No migration needed; push again and the +template gets backfilled from whatever `.env` you push next. + ## Granting, revoking, rotating ```bash diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index 8a0ab11..fa3c4b0 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -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 { +export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise { 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; + const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record; + + // 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; + 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) { diff --git a/src/cli/envFormat.ts b/src/cli/envFormat.ts index b15642c..df72035 100644 --- a/src/cli/envFormat.ts +++ b/src/cli/envFormat.ts @@ -20,3 +20,21 @@ export function serializeEnv(values: Record): 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'); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e686ffa..6d8bf69 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,7 +31,7 @@ async function main(): Promise { 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 keep push [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback) - keep pull [--format env|json] [--out ] [--previous] + keep pull [--format env|json] [--out ] [--previous] [--template] + (--template: comments/structure with values redacted, not the secrets) keep grant [--read-only] keep revoke (admin — needs KEEP_ADMIN_PASSWORD) keep log