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:
17
README.md
17
README.md
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user