@matthesketh/fleet 1.2.0 → 1.7.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 +183 -251
- package/dist/adapters/detector/index.d.ts +8 -0
- package/dist/adapters/detector/index.js +54 -0
- package/dist/adapters/notifier/index.d.ts +2 -0
- package/dist/adapters/notifier/index.js +2 -0
- package/dist/adapters/notifier/stdout.d.ts +2 -0
- package/dist/adapters/notifier/stdout.js +8 -0
- package/dist/adapters/notifier/webhook.d.ts +9 -0
- package/dist/adapters/notifier/webhook.js +38 -0
- package/dist/adapters/runner/claude-cli.d.ts +7 -0
- package/dist/adapters/runner/claude-cli.js +231 -0
- package/dist/adapters/runner/mcp-call.d.ts +8 -0
- package/dist/adapters/runner/mcp-call.js +82 -0
- package/dist/adapters/runner/shell.d.ts +2 -0
- package/dist/adapters/runner/shell.js +103 -0
- package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
- package/dist/adapters/scheduler/systemd-timer.js +149 -0
- package/dist/adapters/signals/ci-status.d.ts +2 -0
- package/dist/adapters/signals/ci-status.js +79 -0
- package/dist/adapters/signals/container-up.d.ts +5 -0
- package/dist/adapters/signals/container-up.js +54 -0
- package/dist/adapters/signals/git-clean.d.ts +2 -0
- package/dist/adapters/signals/git-clean.js +55 -0
- package/dist/adapters/signals/index.d.ts +6 -0
- package/dist/adapters/signals/index.js +7 -0
- package/dist/adapters/types.d.ts +52 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +46 -2
- package/dist/commands/add.js +0 -6
- package/dist/commands/boot-start.d.ts +1 -0
- package/dist/commands/boot-start.js +51 -0
- package/dist/commands/deploy.js +13 -0
- package/dist/commands/deps.js +5 -0
- package/dist/commands/egress.d.ts +1 -0
- package/dist/commands/egress.js +106 -0
- package/dist/commands/freeze.d.ts +4 -0
- package/dist/commands/freeze.js +64 -0
- package/dist/commands/guard.d.ts +1 -0
- package/dist/commands/guard.js +144 -0
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.js +237 -8
- package/dist/commands/patch-systemd.d.ts +1 -0
- package/dist/commands/patch-systemd.js +126 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +58 -0
- package/dist/commands/routine-run.d.ts +1 -0
- package/dist/commands/routine-run.js +122 -0
- package/dist/commands/routines.d.ts +1 -0
- package/dist/commands/routines.js +25 -0
- package/dist/commands/secrets.js +449 -16
- package/dist/commands/status.js +7 -3
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +16 -40
- package/dist/core/boot-refresh.d.ts +57 -0
- package/dist/core/boot-refresh.js +116 -0
- package/dist/core/deps/actors/pr-creator.js +11 -9
- package/dist/core/deps/collectors/docker-running.js +2 -2
- package/dist/core/deps/collectors/github-pr.js +5 -2
- package/dist/core/deps/collectors/npm.js +10 -5
- package/dist/core/deps/collectors/vulnerability.js +10 -6
- package/dist/core/deps/reporters/motd.js +1 -1
- package/dist/core/deps/reporters/telegram.js +2 -29
- package/dist/core/docker.js +45 -15
- package/dist/core/egress.d.ts +41 -0
- package/dist/core/egress.js +161 -0
- package/dist/core/exec.d.ts +7 -1
- package/dist/core/exec.js +25 -17
- package/dist/core/git.d.ts +1 -0
- package/dist/core/git.js +36 -23
- package/dist/core/github.js +27 -8
- package/dist/core/health.d.ts +3 -0
- package/dist/core/health.js +15 -3
- package/dist/core/logs-multi.d.ts +73 -0
- package/dist/core/logs-multi.js +163 -0
- package/dist/core/logs-policy.d.ts +55 -0
- package/dist/core/logs-policy.js +148 -0
- package/dist/core/nginx.js +8 -4
- package/dist/core/notify.d.ts +15 -0
- package/dist/core/notify.js +55 -0
- package/dist/core/registry.d.ts +25 -0
- package/dist/core/registry.js +57 -10
- package/dist/core/routines/cost-queries.d.ts +24 -0
- package/dist/core/routines/cost-queries.js +65 -0
- package/dist/core/routines/db.d.ts +9 -0
- package/dist/core/routines/db.js +126 -0
- package/dist/core/routines/defaults.d.ts +2 -0
- package/dist/core/routines/defaults.js +72 -0
- package/dist/core/routines/engine.d.ts +59 -0
- package/dist/core/routines/engine.js +175 -0
- package/dist/core/routines/incidents.d.ts +13 -0
- package/dist/core/routines/incidents.js +35 -0
- package/dist/core/routines/schema.d.ts +418 -0
- package/dist/core/routines/schema.js +113 -0
- package/dist/core/routines/signals-collector.d.ts +35 -0
- package/dist/core/routines/signals-collector.js +114 -0
- package/dist/core/routines/store.d.ts +316 -0
- package/dist/core/routines/store.js +99 -0
- package/dist/core/routines/test-utils.d.ts +2 -0
- package/dist/core/routines/test-utils.js +13 -0
- package/dist/core/secrets-audit.d.ts +21 -0
- package/dist/core/secrets-audit.js +60 -0
- package/dist/core/secrets-metadata.d.ts +39 -0
- package/dist/core/secrets-metadata.js +82 -0
- package/dist/core/secrets-motd.d.ts +20 -0
- package/dist/core/secrets-motd.js +72 -0
- package/dist/core/secrets-ops.d.ts +3 -1
- package/dist/core/secrets-ops.js +78 -13
- package/dist/core/secrets-providers.d.ts +50 -0
- package/dist/core/secrets-providers.js +291 -0
- package/dist/core/secrets-rotation.d.ts +52 -0
- package/dist/core/secrets-rotation.js +165 -0
- package/dist/core/secrets-snapshots.d.ts +26 -0
- package/dist/core/secrets-snapshots.js +95 -0
- package/dist/core/secrets-validate.js +2 -1
- package/dist/core/secrets.d.ts +12 -1
- package/dist/core/secrets.js +35 -24
- package/dist/core/self-update.d.ts +41 -0
- package/dist/core/self-update.js +73 -0
- package/dist/core/systemd.js +29 -12
- package/dist/core/telegram.d.ts +6 -0
- package/dist/core/telegram.js +32 -0
- package/dist/core/validate.d.ts +7 -0
- package/dist/core/validate.js +42 -0
- package/dist/index.js +0 -4
- package/dist/mcp/deps-tools.js +9 -1
- package/dist/mcp/git-tools.js +4 -4
- package/dist/mcp/server.js +193 -8
- package/dist/templates/systemd.js +3 -3
- package/dist/templates/unseal.js +5 -1
- package/dist/tui/components/KeyHint.js +10 -0
- package/dist/tui/exec-bridge.js +26 -12
- package/dist/tui/hooks/use-fleet-data.js +5 -2
- package/dist/tui/hooks/use-health.js +5 -2
- package/dist/tui/router.js +60 -7
- package/dist/tui/routines/RoutinesApp.d.ts +8 -0
- package/dist/tui/routines/RoutinesApp.js +277 -0
- package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
- package/dist/tui/routines/components/AlertsPanel.js +22 -0
- package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
- package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
- package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
- package/dist/tui/routines/components/CommandPalette.js +21 -0
- package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
- package/dist/tui/routines/components/LiveRunPanel.js +107 -0
- package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
- package/dist/tui/routines/components/RoutineForm.js +254 -0
- package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
- package/dist/tui/routines/components/SignalsGrid.js +34 -0
- package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
- package/dist/tui/routines/format.d.ts +7 -0
- package/dist/tui/routines/format.js +51 -0
- package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
- package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
- package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
- package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
- package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
- package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
- package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
- package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
- package/dist/tui/routines/hooks/use-security.d.ts +33 -0
- package/dist/tui/routines/hooks/use-security.js +110 -0
- package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
- package/dist/tui/routines/hooks/use-signals.js +60 -0
- package/dist/tui/routines/runtime.d.ts +20 -0
- package/dist/tui/routines/runtime.js +40 -0
- package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
- package/dist/tui/routines/tabs/CostTab.js +24 -0
- package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
- package/dist/tui/routines/tabs/DashboardTab.js +10 -0
- package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
- package/dist/tui/routines/tabs/GitTab.js +39 -0
- package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/LogsTab.js +58 -0
- package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/OpsTab.js +34 -0
- package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
- package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
- package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
- package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
- package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
- package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
- package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SecurityTab.js +31 -0
- package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SettingsTab.js +61 -0
- package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
- package/dist/tui/routines/tabs/TimelineTab.js +26 -0
- package/dist/tui/state.js +1 -1
- package/dist/tui/tests/keyboard-integration.test.js +3 -0
- package/dist/tui/tests/test-app.js +1 -1
- package/dist/tui/types.d.ts +2 -2
- package/dist/tui/views/AppDetail.js +3 -4
- package/dist/tui/views/HealthView.js +7 -1
- package/dist/tui/views/LogsView.js +24 -1
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +10 -3
- package/dist/tui/views/SecretsView.js +6 -3
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +34 -21
- package/scripts/guard/cert-expiry-watch +109 -0
- package/scripts/guard/cf-audit-monitor +169 -0
- package/scripts/guard/cf-snapshot +124 -0
- package/scripts/guard/cron.d-cf-protect +11 -0
- package/scripts/guard/dns-drift-watch +138 -0
- package/scripts/guard/fleet-guard +282 -0
- package/scripts/guard/fleet-guard-execute +197 -0
- package/scripts/guard/notify +108 -0
- package/dist/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- package/dist/tui/hooks/use-keyboard.js +0 -44
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MOTD reporter: short summary of secret rotation health, intended for
|
|
3
|
+
* /etc/update-motd.d/99-fleet-secrets to print on shell login.
|
|
4
|
+
*/
|
|
5
|
+
import { enumerateAllSecrets } from './secrets-metadata.js';
|
|
6
|
+
export function summariseSecrets() {
|
|
7
|
+
const all = enumerateAllSecrets();
|
|
8
|
+
const stale = all.filter(s => s.stale);
|
|
9
|
+
const bySensitivity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
10
|
+
for (const s of stale) {
|
|
11
|
+
const sens = s.provider?.sensitivity ?? 'low';
|
|
12
|
+
bySensitivity[sens]++;
|
|
13
|
+
}
|
|
14
|
+
const appsWithStale = Array.from(new Set(stale.map(s => s.app))).sort();
|
|
15
|
+
const sensRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
16
|
+
const topStale = stale
|
|
17
|
+
.slice()
|
|
18
|
+
.sort((a, b) => {
|
|
19
|
+
const sa = sensRank[a.provider?.sensitivity ?? 'low'];
|
|
20
|
+
const sb = sensRank[b.provider?.sensitivity ?? 'low'];
|
|
21
|
+
if (sa !== sb)
|
|
22
|
+
return sa - sb;
|
|
23
|
+
return (b.ageDays ?? 0) - (a.ageDays ?? 0);
|
|
24
|
+
})
|
|
25
|
+
.slice(0, 5)
|
|
26
|
+
.map(s => ({
|
|
27
|
+
app: s.app,
|
|
28
|
+
name: s.name,
|
|
29
|
+
ageDays: s.ageDays ?? 0,
|
|
30
|
+
sensitivity: s.provider?.sensitivity ?? 'low',
|
|
31
|
+
}));
|
|
32
|
+
return {
|
|
33
|
+
totalSecrets: all.length,
|
|
34
|
+
staleCount: stale.length,
|
|
35
|
+
bySensitivity,
|
|
36
|
+
appsWithStale,
|
|
37
|
+
topStale,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function formatSecretsMotd(summary) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push('-- Fleet Secrets ' + '-'.repeat(40));
|
|
43
|
+
if (summary.staleCount === 0) {
|
|
44
|
+
lines.push(` All ${summary.totalSecrets} secrets within rotation frequency`);
|
|
45
|
+
lines.push(' Run: fleet secrets ages');
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
const parts = [];
|
|
49
|
+
if (summary.bySensitivity.critical > 0)
|
|
50
|
+
parts.push(`${summary.bySensitivity.critical} critical`);
|
|
51
|
+
if (summary.bySensitivity.high > 0)
|
|
52
|
+
parts.push(`${summary.bySensitivity.high} high`);
|
|
53
|
+
if (summary.bySensitivity.medium > 0)
|
|
54
|
+
parts.push(`${summary.bySensitivity.medium} medium`);
|
|
55
|
+
if (summary.bySensitivity.low > 0)
|
|
56
|
+
parts.push(`${summary.bySensitivity.low} low`);
|
|
57
|
+
lines.push(` ${summary.staleCount} secrets need rotation (${parts.join(', ')}) across ${summary.appsWithStale.length} apps`);
|
|
58
|
+
for (const t of summary.topStale) {
|
|
59
|
+
const prefix = t.sensitivity === 'critical' ? '!!' : t.sensitivity === 'high' ? ' !' : ' ';
|
|
60
|
+
lines.push(` ${prefix} ${t.app}: ${t.name} (${t.ageDays}d old)`);
|
|
61
|
+
}
|
|
62
|
+
lines.push(' Run: fleet secrets ages --stale-only');
|
|
63
|
+
lines.push(' Rotate: fleet secrets rotate <app>');
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
export function generateSecretsMotdScript() {
|
|
67
|
+
return `#!/bin/bash
|
|
68
|
+
# fleet secrets motd — auto-generated by 'fleet secrets motd-init'
|
|
69
|
+
# shows secret rotation health summary on shell login
|
|
70
|
+
/usr/local/bin/fleet secrets ages --motd 2>/dev/null || true
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
@@ -6,7 +6,9 @@ export interface SealValidation {
|
|
|
6
6
|
export declare function validateBeforeSeal(app: string, newContent: string): SealValidation;
|
|
7
7
|
export declare function safeSealApp(app: string, content: string, sourceFile: string): SealValidation;
|
|
8
8
|
export declare function safeSealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): SealValidation;
|
|
9
|
-
export declare function setSecret(app: string, key: string, value: string
|
|
9
|
+
export declare function setSecret(app: string, key: string, value: string, opts?: {
|
|
10
|
+
allowWeak?: boolean;
|
|
11
|
+
}): void;
|
|
10
12
|
export declare function getSecret(app: string, key: string): string | null;
|
|
11
13
|
export declare function importEnvFile(app: string, path: string): number;
|
|
12
14
|
export declare function importDbSecrets(app: string, dir: string): number;
|
package/dist/core/secrets-ops.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync, mkdirSync, rmSync, statSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { validateAll } from './secrets-validate.js';
|
|
5
|
+
import { execSafe } from './exec.js';
|
|
6
|
+
import { assertAppName, assertSecretKey } from './validate.js';
|
|
5
7
|
import { SecretsError } from './errors.js';
|
|
8
|
+
import { auditLog } from './secrets-audit.js';
|
|
9
|
+
import { checkEntropy } from './secrets-rotation.js';
|
|
10
|
+
import { chownSync } from 'node:fs';
|
|
11
|
+
import { load as loadRegistry } from './registry.js';
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort UID/GID tightening of a runtime secrets file. If the registry
|
|
14
|
+
* defines runtimeUid/runtimeGid for the app, chown to those values; otherwise
|
|
15
|
+
* leave as-is (root:root). Never throws — secret availability beats stricter
|
|
16
|
+
* perms (we already chmod'd 0600 so root-only is the floor).
|
|
17
|
+
*/
|
|
18
|
+
function tryTightenPerms(envPath, app) {
|
|
19
|
+
try {
|
|
20
|
+
const reg = loadRegistry();
|
|
21
|
+
const entry = reg.apps.find(a => a.name === app);
|
|
22
|
+
if (!entry?.runtimeUid && !entry?.runtimeGid)
|
|
23
|
+
return;
|
|
24
|
+
chownSync(envPath, entry.runtimeUid ?? 0, entry.runtimeGid ?? 0);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
// log + continue; never block unseal
|
|
28
|
+
process.stderr.write(`[fleet-unseal] perm tightening skipped for ${app}: ${err}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
6
31
|
import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, } from './secrets.js';
|
|
7
|
-
// ---
|
|
32
|
+
// --- helpers ---
|
|
33
|
+
function safeEqual(a, b) {
|
|
34
|
+
const bufA = Buffer.from(a);
|
|
35
|
+
const bufB = Buffer.from(b);
|
|
36
|
+
if (bufA.length !== bufB.length)
|
|
37
|
+
return false;
|
|
38
|
+
return timingSafeEqual(bufA, bufB);
|
|
39
|
+
}
|
|
8
40
|
function parseEnvKeys(content) {
|
|
9
41
|
return content.split('\n')
|
|
10
42
|
.filter(l => l.includes('=') && !l.startsWith('#') && l.trim())
|
|
@@ -71,7 +103,17 @@ export function safeSealDbSecrets(app, secretsMap, sourceDir) {
|
|
|
71
103
|
}
|
|
72
104
|
return validation;
|
|
73
105
|
}
|
|
74
|
-
export function setSecret(app, key, value) {
|
|
106
|
+
export function setSecret(app, key, value, opts = {}) {
|
|
107
|
+
assertAppName(app);
|
|
108
|
+
assertSecretKey(key);
|
|
109
|
+
// Entropy / placeholder check unless explicitly bypassed.
|
|
110
|
+
if (!opts.allowWeak) {
|
|
111
|
+
const entropyErr = checkEntropy(value);
|
|
112
|
+
if (entropyErr) {
|
|
113
|
+
auditLog({ op: 'set', app, secret: key, ok: false, details: `weak value rejected: ${entropyErr}` });
|
|
114
|
+
throw new SecretsError(`${entropyErr}. Pass --allow-weak to override (not recommended).`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
75
117
|
const plaintext = decryptApp(app);
|
|
76
118
|
const manifest = loadManifest();
|
|
77
119
|
const entry = manifest.apps[app];
|
|
@@ -90,6 +132,7 @@ export function setSecret(app, key, value) {
|
|
|
90
132
|
if (!found)
|
|
91
133
|
updated.push(`${key}=${value}`);
|
|
92
134
|
safeSealApp(app, updated.join('\n'), entry.sourceFile);
|
|
135
|
+
auditLog({ op: 'set', app, secret: key, ok: true });
|
|
93
136
|
}
|
|
94
137
|
export function getSecret(app, key) {
|
|
95
138
|
const plaintext = decryptApp(app);
|
|
@@ -107,6 +150,10 @@ export function getSecret(app, key) {
|
|
|
107
150
|
const files = parseSecretsBundle(plaintext);
|
|
108
151
|
return files[key] ?? null;
|
|
109
152
|
}
|
|
153
|
+
// Note: getSecret is read-only; we audit at the command layer to record
|
|
154
|
+
// human-driven reads (set/get/import/export). Programmatic reads done by
|
|
155
|
+
// other fleet operations (sealing, validation, drift) are not audited to
|
|
156
|
+
// avoid log noise.
|
|
110
157
|
export function importEnvFile(app, path) {
|
|
111
158
|
if (!existsSync(path))
|
|
112
159
|
throw new SecretsError(`File not found: ${path}`);
|
|
@@ -119,9 +166,11 @@ export function importEnvFile(app, path) {
|
|
|
119
166
|
}
|
|
120
167
|
catch (err) {
|
|
121
168
|
restoreVaultFile(app);
|
|
169
|
+
auditLog({ op: 'import', app, ok: false, details: `${path}: ${err}` });
|
|
122
170
|
throw err;
|
|
123
171
|
}
|
|
124
172
|
const manifest = loadManifest();
|
|
173
|
+
auditLog({ op: 'import', app, ok: true, details: `${path}: ${manifest.apps[app].keyCount} keys` });
|
|
125
174
|
return manifest.apps[app].keyCount;
|
|
126
175
|
}
|
|
127
176
|
export function importDbSecrets(app, dir) {
|
|
@@ -148,6 +197,7 @@ export function importDbSecrets(app, dir) {
|
|
|
148
197
|
return files.length;
|
|
149
198
|
}
|
|
150
199
|
export function exportApp(app) {
|
|
200
|
+
auditLog({ op: 'export', app, ok: true });
|
|
151
201
|
return decryptApp(app);
|
|
152
202
|
}
|
|
153
203
|
export function detectDrift(app) {
|
|
@@ -172,7 +222,7 @@ export function detectDrift(app) {
|
|
|
172
222
|
const runtimeMap = parseEnvMap(runtimeContent);
|
|
173
223
|
const addedKeys = Object.keys(runtimeMap).filter(k => !(k in vaultMap));
|
|
174
224
|
const removedKeys = Object.keys(vaultMap).filter(k => !(k in runtimeMap));
|
|
175
|
-
const changedKeys = Object.keys(vaultMap).filter(k => k in runtimeMap && vaultMap[k]
|
|
225
|
+
const changedKeys = Object.keys(vaultMap).filter(k => k in runtimeMap && !safeEqual(vaultMap[k], runtimeMap[k]));
|
|
176
226
|
const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
|
|
177
227
|
results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
|
|
178
228
|
}
|
|
@@ -191,7 +241,7 @@ export function detectDrift(app) {
|
|
|
191
241
|
}
|
|
192
242
|
const addedKeys = runtimeFiles.filter(f => !(f in vaultFiles));
|
|
193
243
|
const removedKeys = Object.keys(vaultFiles).filter(f => !(f in runtimeMap));
|
|
194
|
-
const changedKeys = Object.keys(vaultFiles).filter(f => f in runtimeMap && vaultFiles[f]
|
|
244
|
+
const changedKeys = Object.keys(vaultFiles).filter(f => f in runtimeMap && !safeEqual(vaultFiles[f], runtimeMap[f]));
|
|
195
245
|
const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
|
|
196
246
|
results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
|
|
197
247
|
}
|
|
@@ -213,6 +263,7 @@ function parseEnvMap(content) {
|
|
|
213
263
|
// --- Phase 4: Improved unseal (validate before write) ---
|
|
214
264
|
export function unsealAll() {
|
|
215
265
|
const manifest = loadManifest();
|
|
266
|
+
auditLog({ op: 'unseal', ok: true, details: `apps=${Object.keys(manifest.apps).length}` });
|
|
216
267
|
// Phase 4: Decrypt all apps first and validate BEFORE writing to runtime
|
|
217
268
|
const decrypted = {};
|
|
218
269
|
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
@@ -243,17 +294,29 @@ export function unsealAll() {
|
|
|
243
294
|
const envPath = join(appDir, '.env');
|
|
244
295
|
writeFileSync(envPath, plaintext);
|
|
245
296
|
chmodSync(envPath, 0o600);
|
|
297
|
+
// Optional UID/GID tightening (registry.runtimeUid/runtimeGid). Default
|
|
298
|
+
// root:root if unset. Failures are non-fatal — if the UID doesn't exist
|
|
299
|
+
// we'd rather have the secret available than fail boot.
|
|
300
|
+
tryTightenPerms(envPath, app);
|
|
246
301
|
}
|
|
247
302
|
else if (entry.type === 'secrets-dir') {
|
|
248
303
|
const secretsDir = join(RUNTIME_DIR, app, 'secrets');
|
|
249
304
|
if (!existsSync(secretsDir))
|
|
250
|
-
mkdirSync(secretsDir, { recursive: true, mode:
|
|
305
|
+
mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
251
306
|
const parsed = parseSecretsBundle(plaintext);
|
|
252
307
|
for (const [filename, content] of Object.entries(parsed)) {
|
|
253
|
-
const
|
|
308
|
+
const safe = basename(filename);
|
|
309
|
+
if (safe !== filename || filename.includes('..')) {
|
|
310
|
+
throw new SecretsError(`Invalid secret filename: ${filename}`);
|
|
311
|
+
}
|
|
312
|
+
const fpath = join(secretsDir, safe);
|
|
254
313
|
writeFileSync(fpath, content);
|
|
255
|
-
// 0644: docker
|
|
256
|
-
//
|
|
314
|
+
// 0644: docker bind-mounts these files into containers where non-root
|
|
315
|
+
// processes need read access. group-only (0640) breaks mongo's
|
|
316
|
+
// entrypoint, which reads the password file as uid 999 (mongodb)
|
|
317
|
+
// without first reading as root the way postgres does. host security
|
|
318
|
+
// still relies on the parent dir being 0700 root:root, so 0644 here
|
|
319
|
+
// does not widen host exposure.
|
|
257
320
|
chmodSync(fpath, 0o644);
|
|
258
321
|
}
|
|
259
322
|
}
|
|
@@ -297,8 +360,10 @@ export function rotateKey() {
|
|
|
297
360
|
decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
|
|
298
361
|
}
|
|
299
362
|
const backupPath = KEY_PATH + '.old';
|
|
300
|
-
|
|
301
|
-
|
|
363
|
+
copyFileSync(KEY_PATH, backupPath);
|
|
364
|
+
const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
|
|
365
|
+
if (!keygen.ok)
|
|
366
|
+
throw new SecretsError(`Failed to generate new key: ${keygen.stderr}`);
|
|
302
367
|
chmodSync(KEY_PATH, 0o600);
|
|
303
368
|
const newPubkey = getPublicKey();
|
|
304
369
|
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry: maps secret names to provider metadata used by
|
|
3
|
+
* `fleet secrets rotate` and `fleet secrets ages`.
|
|
4
|
+
*
|
|
5
|
+
* Adding a new provider: append to PROVIDERS. Order matters — the FIRST
|
|
6
|
+
* matching entry wins, so put more specific patterns before generic ones.
|
|
7
|
+
*
|
|
8
|
+
* Strategy reference:
|
|
9
|
+
* - immediate : replace value, old dies instantly. Safe for upstream API keys.
|
|
10
|
+
* - dual-mode : new value becomes primary, old is kept as <NAME>_PREVIOUS for a
|
|
11
|
+
* grace period so existing user sessions/tokens still verify.
|
|
12
|
+
* Requires app code to read the _PREVIOUS variant as a fallback.
|
|
13
|
+
* - at-rest-key : encrypts data sitting in storage. Rotating without re-encrypting
|
|
14
|
+
* bricks the data. Refused unless --data-migrated is passed.
|
|
15
|
+
* - user-issued : tokens YOU give to YOUR users. Rotating yours doesn't help —
|
|
16
|
+
* redirected to per-user rotation tooling.
|
|
17
|
+
*/
|
|
18
|
+
export type RotationStrategy = 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued';
|
|
19
|
+
export type Sensitivity = 'low' | 'medium' | 'high' | 'critical';
|
|
20
|
+
export interface ProviderDef {
|
|
21
|
+
/** Stable id for manifest persistence. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Pattern matched against the secret name (env var key). */
|
|
24
|
+
matches: RegExp;
|
|
25
|
+
/** Human label shown in the UI. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Where to go to regenerate this secret. */
|
|
28
|
+
url?: string;
|
|
29
|
+
/** Numbered, copy-pasteable rotation steps. */
|
|
30
|
+
instructions?: string;
|
|
31
|
+
/** Format the new value should match. Used to validate paste. */
|
|
32
|
+
format?: RegExp;
|
|
33
|
+
/** Severity if this leaks. Drives MOTD ordering. */
|
|
34
|
+
sensitivity: Sensitivity;
|
|
35
|
+
/** How often this secret should be rotated, in days. Drives staleness. */
|
|
36
|
+
rotationFrequencyDays: number;
|
|
37
|
+
/** How rotation should be performed. See file header. */
|
|
38
|
+
strategy: RotationStrategy;
|
|
39
|
+
/** Optional: pretty companion env var name for dual-mode rotations. */
|
|
40
|
+
previousVarName?: (varName: string) => string;
|
|
41
|
+
}
|
|
42
|
+
export declare const PROVIDERS: ProviderDef[];
|
|
43
|
+
/** Find the provider definition that matches the given secret name. */
|
|
44
|
+
export declare function classifySecret(name: string): ProviderDef | null;
|
|
45
|
+
/** Look up by stored provider id (for round-tripping after manifest persistence). */
|
|
46
|
+
export declare function getProviderById(id: string): ProviderDef | null;
|
|
47
|
+
/** Days since a timestamp. Null if invalid. */
|
|
48
|
+
export declare function ageInDays(iso: string | undefined): number | null;
|
|
49
|
+
/** True if the secret is older than its provider's rotationFrequencyDays. */
|
|
50
|
+
export declare function isStale(age: number | null, provider: ProviderDef | null): boolean;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry: maps secret names to provider metadata used by
|
|
3
|
+
* `fleet secrets rotate` and `fleet secrets ages`.
|
|
4
|
+
*
|
|
5
|
+
* Adding a new provider: append to PROVIDERS. Order matters — the FIRST
|
|
6
|
+
* matching entry wins, so put more specific patterns before generic ones.
|
|
7
|
+
*
|
|
8
|
+
* Strategy reference:
|
|
9
|
+
* - immediate : replace value, old dies instantly. Safe for upstream API keys.
|
|
10
|
+
* - dual-mode : new value becomes primary, old is kept as <NAME>_PREVIOUS for a
|
|
11
|
+
* grace period so existing user sessions/tokens still verify.
|
|
12
|
+
* Requires app code to read the _PREVIOUS variant as a fallback.
|
|
13
|
+
* - at-rest-key : encrypts data sitting in storage. Rotating without re-encrypting
|
|
14
|
+
* bricks the data. Refused unless --data-migrated is passed.
|
|
15
|
+
* - user-issued : tokens YOU give to YOUR users. Rotating yours doesn't help —
|
|
16
|
+
* redirected to per-user rotation tooling.
|
|
17
|
+
*/
|
|
18
|
+
const previousAsSuffix = (n) => `${n}_PREVIOUS`;
|
|
19
|
+
export const PROVIDERS = [
|
|
20
|
+
// ── Stripe ───────────────────────────────────────────────────────────────
|
|
21
|
+
{
|
|
22
|
+
id: 'stripe-secret-key',
|
|
23
|
+
matches: /^STRIPE_SECRET_KEY$/,
|
|
24
|
+
name: 'Stripe Secret Key',
|
|
25
|
+
url: 'https://dashboard.stripe.com/apikeys',
|
|
26
|
+
instructions: '1. Click "Create secret key" (or "Create restricted key" — both work here)\n' +
|
|
27
|
+
'2. Set restrictions if desired (RESTRICTED keys are recommended for least-privilege)\n' +
|
|
28
|
+
'3. Copy the new sk_live_... or rk_live_... value and paste below\n' +
|
|
29
|
+
'4. After confirming the new key works, revoke the old one in the dashboard',
|
|
30
|
+
// Accept both standard (sk_) and restricted (rk_) Stripe API keys. Both are
|
|
31
|
+
// valid values for the STRIPE_SECRET_KEY env var; restricted keys are
|
|
32
|
+
// Stripe's recommended pattern for least-privilege production use.
|
|
33
|
+
format: /^(sk|rk)_(live|test)_[A-Za-z0-9]{40,}$/,
|
|
34
|
+
sensitivity: 'critical',
|
|
35
|
+
rotationFrequencyDays: 90,
|
|
36
|
+
strategy: 'immediate',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'stripe-restricted-key',
|
|
40
|
+
matches: /^STRIPE_RESTRICTED_KEY$/,
|
|
41
|
+
name: 'Stripe Restricted Key',
|
|
42
|
+
url: 'https://dashboard.stripe.com/apikeys',
|
|
43
|
+
format: /^rk_(live|test)_[A-Za-z0-9]{40,}$/,
|
|
44
|
+
sensitivity: 'high',
|
|
45
|
+
rotationFrequencyDays: 90,
|
|
46
|
+
strategy: 'immediate',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'stripe-webhook-secret',
|
|
50
|
+
matches: /^STRIPE_WEBHOOK_SECRET$/,
|
|
51
|
+
name: 'Stripe Webhook Signing Secret',
|
|
52
|
+
url: 'https://dashboard.stripe.com/webhooks',
|
|
53
|
+
instructions: '1. Click your webhook endpoint\n' +
|
|
54
|
+
'2. Click "Roll secret"\n' +
|
|
55
|
+
'3. Copy the new whsec_... value and paste below',
|
|
56
|
+
format: /^whsec_[A-Za-z0-9]{20,}$/,
|
|
57
|
+
sensitivity: 'high',
|
|
58
|
+
rotationFrequencyDays: 90,
|
|
59
|
+
strategy: 'immediate',
|
|
60
|
+
},
|
|
61
|
+
// ── GitHub ───────────────────────────────────────────────────────────────
|
|
62
|
+
{
|
|
63
|
+
id: 'github-pat-classic',
|
|
64
|
+
matches: /^(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT)$/,
|
|
65
|
+
name: 'GitHub Personal Access Token',
|
|
66
|
+
url: 'https://github.com/settings/tokens',
|
|
67
|
+
instructions: '1. Click "Generate new token (classic)" or use a fine-grained token\n' +
|
|
68
|
+
'2. Match the scopes/permissions of the existing token\n' +
|
|
69
|
+
'3. Copy the new ghp_... or github_pat_... value and paste below\n' +
|
|
70
|
+
'4. Delete the old token from the same page once confirmed working',
|
|
71
|
+
format: /^(ghp_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})$/,
|
|
72
|
+
sensitivity: 'critical',
|
|
73
|
+
rotationFrequencyDays: 90,
|
|
74
|
+
strategy: 'immediate',
|
|
75
|
+
},
|
|
76
|
+
// ── AI providers ─────────────────────────────────────────────────────────
|
|
77
|
+
{
|
|
78
|
+
id: 'anthropic-api-key',
|
|
79
|
+
matches: /^ANTHROPIC_API_KEY$/,
|
|
80
|
+
name: 'Anthropic API Key',
|
|
81
|
+
url: 'https://console.anthropic.com/settings/keys',
|
|
82
|
+
format: /^sk-ant-[A-Za-z0-9_\-]{40,}$/,
|
|
83
|
+
sensitivity: 'high',
|
|
84
|
+
rotationFrequencyDays: 90,
|
|
85
|
+
strategy: 'immediate',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'openai-api-key',
|
|
89
|
+
matches: /^OPENAI_API_KEY$/,
|
|
90
|
+
name: 'OpenAI API Key',
|
|
91
|
+
url: 'https://platform.openai.com/api-keys',
|
|
92
|
+
format: /^sk-(proj-)?[A-Za-z0-9_\-]{20,}$/,
|
|
93
|
+
sensitivity: 'high',
|
|
94
|
+
rotationFrequencyDays: 90,
|
|
95
|
+
strategy: 'immediate',
|
|
96
|
+
},
|
|
97
|
+
// ── Google ───────────────────────────────────────────────────────────────
|
|
98
|
+
{
|
|
99
|
+
id: 'google-oauth-client-secret',
|
|
100
|
+
matches: /^(GOOGLE_CLIENT_SECRET|GOOGLE_OAUTH_CLIENT_SECRET)$/,
|
|
101
|
+
name: 'Google OAuth Client Secret',
|
|
102
|
+
url: 'https://console.cloud.google.com/apis/credentials',
|
|
103
|
+
instructions: '1. Open the OAuth 2.0 Client ID for this app\n' +
|
|
104
|
+
'2. Add a new client secret (Google now supports multiple)\n' +
|
|
105
|
+
'3. Paste the new GOCSPX-... value below\n' +
|
|
106
|
+
'4. Delete the old secret from the same page after verifying',
|
|
107
|
+
format: /^GOCSPX-[A-Za-z0-9_\-]{20,}$/,
|
|
108
|
+
sensitivity: 'high',
|
|
109
|
+
rotationFrequencyDays: 180,
|
|
110
|
+
strategy: 'immediate',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'google-api-key',
|
|
114
|
+
matches: /^(GOOGLE_API_KEY|GMAPS_API_KEY|GEMINI_API_KEY)$/,
|
|
115
|
+
name: 'Google API Key',
|
|
116
|
+
url: 'https://console.cloud.google.com/apis/credentials',
|
|
117
|
+
format: /^AIza[0-9A-Za-z_\-]{35}$/,
|
|
118
|
+
sensitivity: 'medium',
|
|
119
|
+
rotationFrequencyDays: 180,
|
|
120
|
+
strategy: 'immediate',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'gmail-app-password',
|
|
124
|
+
matches: /^(EMAIL_SERVER_PASSWORD|GMAIL_APP_PASSWORD|SMTP_PASS|SMTP_PASSWORD)$/,
|
|
125
|
+
name: 'Gmail App Password / SMTP Password',
|
|
126
|
+
url: 'https://myaccount.google.com/apppasswords',
|
|
127
|
+
instructions: '1. Sign in and create a new App Password (e.g. "macpool-2026")\n' +
|
|
128
|
+
'2. Copy the 16-character value and paste below WITHOUT spaces\n' +
|
|
129
|
+
'3. Revoke the old App Password from the same page',
|
|
130
|
+
// Gmail app passwords are 16 lowercase alphanumeric chars. Google
|
|
131
|
+
// displays them with spaces every 4 chars for readability but expects
|
|
132
|
+
// them WITHOUT spaces in the SMTP password field — we accept only the
|
|
133
|
+
// de-spaced form so a paste-as-displayed gets rejected with a clear error.
|
|
134
|
+
format: /^[a-z0-9]{16}$/,
|
|
135
|
+
sensitivity: 'critical',
|
|
136
|
+
rotationFrequencyDays: 90,
|
|
137
|
+
strategy: 'immediate',
|
|
138
|
+
},
|
|
139
|
+
// ── App-internal cryptographic secrets (DUAL-MODE — preserves user sessions) ─
|
|
140
|
+
{
|
|
141
|
+
id: 'jwt-secret',
|
|
142
|
+
matches: /^(JWT_SECRET|JWT_SIGNING_SECRET|JWT_PRIVATE_KEY)$/,
|
|
143
|
+
name: 'JWT Signing Secret',
|
|
144
|
+
instructions: 'Generate a fresh high-entropy value (e.g. `openssl rand -base64 64`).\n' +
|
|
145
|
+
'NOTE: Your app must read JWT_SECRET_PREVIOUS as a fallback verifier so\n' +
|
|
146
|
+
'existing tokens stay valid through the grace period.',
|
|
147
|
+
sensitivity: 'critical',
|
|
148
|
+
rotationFrequencyDays: 180,
|
|
149
|
+
strategy: 'dual-mode',
|
|
150
|
+
previousVarName: previousAsSuffix,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'nextauth-secret',
|
|
154
|
+
matches: /^(NEXTAUTH_SECRET|AUTH_SECRET)$/,
|
|
155
|
+
name: 'NextAuth / Auth.js Secret',
|
|
156
|
+
instructions: 'Generate with `openssl rand -base64 64`.\n' +
|
|
157
|
+
'NextAuth supports multiple secrets in v5; configure both new and previous so\n' +
|
|
158
|
+
'logged-in users remain logged in.',
|
|
159
|
+
sensitivity: 'critical',
|
|
160
|
+
rotationFrequencyDays: 180,
|
|
161
|
+
strategy: 'dual-mode',
|
|
162
|
+
previousVarName: previousAsSuffix,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'session-secret',
|
|
166
|
+
matches: /^(SESSION_SECRET|COOKIE_SECRET|EXPRESS_SESSION_SECRET)$/,
|
|
167
|
+
name: 'Session / Cookie Signing Secret',
|
|
168
|
+
instructions: 'Generate with `openssl rand -base64 64`.',
|
|
169
|
+
sensitivity: 'high',
|
|
170
|
+
rotationFrequencyDays: 180,
|
|
171
|
+
strategy: 'dual-mode',
|
|
172
|
+
previousVarName: previousAsSuffix,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'csrf-secret',
|
|
176
|
+
matches: /^(CSRF_SECRET|CSRF_TOKEN_SECRET)$/,
|
|
177
|
+
name: 'CSRF Token Secret',
|
|
178
|
+
instructions: 'Generate with `openssl rand -base64 64`.',
|
|
179
|
+
sensitivity: 'medium',
|
|
180
|
+
rotationFrequencyDays: 180,
|
|
181
|
+
strategy: 'dual-mode',
|
|
182
|
+
previousVarName: previousAsSuffix,
|
|
183
|
+
},
|
|
184
|
+
// ── Encryption-at-rest (REFUSED unless --data-migrated) ──────────────────
|
|
185
|
+
{
|
|
186
|
+
id: 'data-encryption-key',
|
|
187
|
+
matches: /^(ENCRYPTION_KEY|DATA_ENCRYPTION_KEY|FIELD_ENCRYPTION_KEY|AT_REST_KEY)$/,
|
|
188
|
+
name: 'At-Rest Data Encryption Key',
|
|
189
|
+
instructions: 'WARNING: Rotating this without re-encrypting stored data will make the data\n' +
|
|
190
|
+
'unreadable. Re-encrypt all data with the new key BEFORE rotation, then run:\n' +
|
|
191
|
+
' fleet secrets rotate <app> <KEY> --data-migrated',
|
|
192
|
+
sensitivity: 'critical',
|
|
193
|
+
rotationFrequencyDays: 365,
|
|
194
|
+
strategy: 'at-rest-key',
|
|
195
|
+
},
|
|
196
|
+
// ── Bookwhen (used by macpool) ───────────────────────────────────────────
|
|
197
|
+
{
|
|
198
|
+
id: 'bookwhen-token',
|
|
199
|
+
matches: /^BOOKWHEN_API_TOKEN$/,
|
|
200
|
+
name: 'Bookwhen API Token',
|
|
201
|
+
url: 'https://bookwhen.com/account/api',
|
|
202
|
+
sensitivity: 'medium',
|
|
203
|
+
rotationFrequencyDays: 180,
|
|
204
|
+
strategy: 'immediate',
|
|
205
|
+
},
|
|
206
|
+
// ── Database connection strings ──────────────────────────────────────────
|
|
207
|
+
{
|
|
208
|
+
id: 'database-url',
|
|
209
|
+
matches: /^(DATABASE_URL|MONGO_URL|REDIS_URL|POSTGRES_URL|MYSQL_URL)$/,
|
|
210
|
+
name: 'Database Connection String',
|
|
211
|
+
instructions: 'Update the password component only — keep host, port, db unchanged.\n' +
|
|
212
|
+
'Rotate the underlying DB user password first, then update this URL.',
|
|
213
|
+
sensitivity: 'critical',
|
|
214
|
+
rotationFrequencyDays: 180,
|
|
215
|
+
strategy: 'immediate',
|
|
216
|
+
},
|
|
217
|
+
// ── AWS ──────────────────────────────────────────────────────────────────
|
|
218
|
+
{
|
|
219
|
+
id: 'aws-access-key',
|
|
220
|
+
matches: /^AWS_ACCESS_KEY_ID$/,
|
|
221
|
+
name: 'AWS Access Key ID',
|
|
222
|
+
url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
|
|
223
|
+
format: /^AKIA[0-9A-Z]{16}$/,
|
|
224
|
+
sensitivity: 'critical',
|
|
225
|
+
rotationFrequencyDays: 90,
|
|
226
|
+
strategy: 'immediate',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'aws-secret-key',
|
|
230
|
+
matches: /^AWS_SECRET_ACCESS_KEY$/,
|
|
231
|
+
name: 'AWS Secret Access Key',
|
|
232
|
+
url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
|
|
233
|
+
format: /^[A-Za-z0-9/+=]{40}$/,
|
|
234
|
+
sensitivity: 'critical',
|
|
235
|
+
rotationFrequencyDays: 90,
|
|
236
|
+
strategy: 'immediate',
|
|
237
|
+
},
|
|
238
|
+
// ── Tokens we issue to OUR users (refused — rotate per user) ─────────────
|
|
239
|
+
{
|
|
240
|
+
id: 'user-issued-token',
|
|
241
|
+
matches: /^(USER_API_TOKEN|CUSTOMER_API_KEYS|TENANT_TOKENS)$/,
|
|
242
|
+
name: 'User-Issued Token',
|
|
243
|
+
instructions: 'These are tokens YOU issue to YOUR users. Rotating yours does nothing — you\n' +
|
|
244
|
+
'need a per-user revocation flow in your app to invalidate them.',
|
|
245
|
+
sensitivity: 'high',
|
|
246
|
+
rotationFrequencyDays: 365,
|
|
247
|
+
strategy: 'user-issued',
|
|
248
|
+
},
|
|
249
|
+
// ── Generic fallback ─────────────────────────────────────────────────────
|
|
250
|
+
// Anything looking like a secret name but not specifically known.
|
|
251
|
+
// Require an explicit `_` boundary before the suffix (so `MONKEY` and
|
|
252
|
+
// `BROKEN_KEY` no longer match) and exclude `PUBLIC_KEY` / `PUB_KEY`
|
|
253
|
+
// which are not secrets despite ending in KEY.
|
|
254
|
+
{
|
|
255
|
+
id: 'generic-secret',
|
|
256
|
+
matches: /^(?!.*(?:PUBLIC_KEY|PUB_KEY)$).*_(SECRET|TOKEN|KEY|PASSWORD|PRIVATE)$/i,
|
|
257
|
+
name: 'Generic Secret',
|
|
258
|
+
instructions: 'Generate a fresh high-entropy value, e.g. `openssl rand -base64 32`.',
|
|
259
|
+
sensitivity: 'medium',
|
|
260
|
+
rotationFrequencyDays: 180,
|
|
261
|
+
strategy: 'immediate',
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
/** Find the provider definition that matches the given secret name. */
|
|
265
|
+
export function classifySecret(name) {
|
|
266
|
+
for (const p of PROVIDERS) {
|
|
267
|
+
if (p.matches.test(name))
|
|
268
|
+
return p;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
/** Look up by stored provider id (for round-tripping after manifest persistence). */
|
|
273
|
+
export function getProviderById(id) {
|
|
274
|
+
return PROVIDERS.find(p => p.id === id) ?? null;
|
|
275
|
+
}
|
|
276
|
+
/** Days since a timestamp. Null if invalid. */
|
|
277
|
+
export function ageInDays(iso) {
|
|
278
|
+
if (!iso)
|
|
279
|
+
return null;
|
|
280
|
+
const t = Date.parse(iso);
|
|
281
|
+
if (isNaN(t))
|
|
282
|
+
return null;
|
|
283
|
+
const ms = Date.now() - t;
|
|
284
|
+
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
285
|
+
}
|
|
286
|
+
/** True if the secret is older than its provider's rotationFrequencyDays. */
|
|
287
|
+
export function isStale(age, provider) {
|
|
288
|
+
if (age == null || provider == null)
|
|
289
|
+
return false;
|
|
290
|
+
return age >= provider.rotationFrequencyDays;
|
|
291
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation engine: takes a parsed plaintext env, applies a per-secret rotation
|
|
3
|
+
* (immediate, dual-mode, etc.), re-seals, restarts the service, runs a health
|
|
4
|
+
* gate, and rolls back on failure. Pure functions where possible — I/O isolated
|
|
5
|
+
* to thin wrappers so tests can run without a real vault.
|
|
6
|
+
*/
|
|
7
|
+
import { type ProviderDef } from './secrets-providers.js';
|
|
8
|
+
/** Mask a NEW (just-entered) secret value for confirmation display. */
|
|
9
|
+
export declare function maskNewValue(value: string): string;
|
|
10
|
+
/** Validate against provider format if any. Returns null on pass, error string on fail. */
|
|
11
|
+
export declare function validateFormat(value: string, provider: ProviderDef | null): string | null;
|
|
12
|
+
/** Reject obvious placeholders / low-entropy strings. */
|
|
13
|
+
export declare function checkEntropy(value: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Parse an .env-style plaintext into ordered entries. Preserves comments
|
|
16
|
+
* and blank lines so a re-serialised file is diff-friendly.
|
|
17
|
+
*/
|
|
18
|
+
export type EnvLine = {
|
|
19
|
+
kind: 'kv';
|
|
20
|
+
key: string;
|
|
21
|
+
value: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'raw';
|
|
24
|
+
text: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function parseEnv(plaintext: string): EnvLine[];
|
|
27
|
+
export declare function serialiseEnv(lines: EnvLine[]): string;
|
|
28
|
+
/**
|
|
29
|
+
* Apply a single secret update. For dual-mode strategies, the OLD value is
|
|
30
|
+
* preserved as <NAME>_PREVIOUS so the app can verify legacy tokens during
|
|
31
|
+
* the grace period. Returns the new env content.
|
|
32
|
+
*/
|
|
33
|
+
export declare function applyRotation(plaintext: string, key: string, newValue: string, strategy: 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued'): string;
|
|
34
|
+
export interface RotationResult {
|
|
35
|
+
app: string;
|
|
36
|
+
key: string;
|
|
37
|
+
strategy: string;
|
|
38
|
+
snapshot: string;
|
|
39
|
+
rolledBack: boolean;
|
|
40
|
+
reason?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Full rotation pipeline. Caller is expected to have already collected and
|
|
44
|
+
* validated the new value via the interactive prompts. This orchestrates
|
|
45
|
+
* snapshot → seal → audit. Restart + health-gate are caller's responsibility
|
|
46
|
+
* (we want the engine pure-ish so it's easy to test).
|
|
47
|
+
*/
|
|
48
|
+
export declare function performRotation(app: string, key: string, newValue: string, opts?: {
|
|
49
|
+
dryRun?: boolean;
|
|
50
|
+
notes?: string;
|
|
51
|
+
dataMigrated?: boolean;
|
|
52
|
+
}): RotationResult;
|