Save a redacted .env template alongside pushed secrets
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:
Fredrik Johansson
2026-07-15 20:41:45 +02:00
parent bb7f890a68
commit 8dcb675ad2
4 changed files with 64 additions and 7 deletions

View File

@@ -116,6 +116,23 @@ keep push myapp/production --file .env.production
keep pull myapp/production > .env 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 ## Granting, revoking, rotating
```bash ```bash

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { requireIdentityAndRecipientId } from '../identityStore.js'; import { requireIdentityAndRecipientId } from '../identityStore.js';
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js'; import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
import { parseEnv, serializeEnv } from '../envFormat.js'; import { parseEnv, serializeEnv, buildEnvTemplate } from '../envFormat.js';
import { import {
ready, ready,
Identity, Identity,
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
const { identity, recipientId } = await requireIdentityAndRecipientId(); const { identity, recipientId } = await requireIdentityAndRecipientId();
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`); if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
const values = parseEnv(fs.readFileSync(file, 'utf8')); const raw = fs.readFileSync(file, 'utf8');
const payload = enc.fromUtf8(JSON.stringify(values)); 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`)); 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)' : ''}.`); 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(); await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId(); 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'); 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 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); const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
if (outFile) { if (outFile) {

View File

@@ -20,3 +20,21 @@ export function serializeEnv(values: Record<string, string>): string {
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`) .map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
.join('\n') + '\n'; .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');
}

View File

@@ -31,7 +31,7 @@ async function main(): Promise<void> {
if (cmd === 'overview') return await adminOverview(); if (cmd === 'overview') return await adminOverview();
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge')); 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 === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub); if (cmd === 'log') return await vaultLog(sub);
@@ -54,7 +54,8 @@ function printUsage(): void {
keep identity set-id <recipient-id> keep identity set-id <recipient-id>
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback) 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 grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD) keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault> keep log <vault>