@caelo-cms/provisioning 0.10.47 → 0.10.49
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #656 — progress.json / install.json never hold secret material, and
|
|
5
|
+
* `upgrade` / wizard re-runs scrub what earlier installers stored. Every
|
|
6
|
+
* credential below is a fake fixture.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { CAELO_VERSION } from "@caelo-cms/shared";
|
|
22
|
+
import {
|
|
23
|
+
assertNoCheckpointSecrets,
|
|
24
|
+
assertNoSecrets,
|
|
25
|
+
checkpointableOutputs,
|
|
26
|
+
findCheckpointSecrets,
|
|
27
|
+
findSecrets,
|
|
28
|
+
redactCredentials,
|
|
29
|
+
rotationHints,
|
|
30
|
+
scrubProgressFile,
|
|
31
|
+
} from "./checkpoint-secrets.js";
|
|
32
|
+
import { databaseUrls } from "./stack-contract.js";
|
|
33
|
+
|
|
34
|
+
const FAKE_PW = "fakePw0rdForTests123";
|
|
35
|
+
const legacyAdminUrl = `postgresql://admin_role:${FAKE_PW}@10.1.2.3:5432/cms_admin?sslmode=require`;
|
|
36
|
+
const legacyPublicUrl = `postgresql://public_role:${FAKE_PW}@10.1.2.3:5432/cms_public?sslmode=require`;
|
|
37
|
+
const setupLink = "https://admin.acme.test/setup?token=fakeSetupToken0123456789";
|
|
38
|
+
|
|
39
|
+
/** What `pulumi up` returns on the current stacks, secret flags included. */
|
|
40
|
+
const stackOutputs = {
|
|
41
|
+
adminDatabaseUrlOut: { value: databaseUrls("10.1.2.3").admin, secret: false },
|
|
42
|
+
publicDatabaseUrlOut: { value: databaseUrls("10.1.2.3").public, secret: false },
|
|
43
|
+
gatewayDatabaseUrlOut: { value: databaseUrls("10.1.2.3").gateway, secret: false },
|
|
44
|
+
bootstrapUrlOut: { value: setupLink, secret: false },
|
|
45
|
+
bootstrapTokenExpiresAtOut: { value: "2026-10-13T00:00:00.000Z", secret: false },
|
|
46
|
+
providerOut: { value: "gcp", secret: false },
|
|
47
|
+
lbIpOut: { value: "203.0.113.7", secret: false },
|
|
48
|
+
adminDomainOut: { value: "admin.acme.test", secret: false },
|
|
49
|
+
adminCloudRunUrlOut: { value: "https://caelo-admin-x.a.run.app", secret: false },
|
|
50
|
+
dnsRecordsRequiredOut: {
|
|
51
|
+
value: [{ hostname: "acme.test", type: "A", value: "203.0.113.7", purpose: "site" }],
|
|
52
|
+
secret: false,
|
|
53
|
+
},
|
|
54
|
+
// A future output Pulumi marks secret, whatever its name or shape.
|
|
55
|
+
someFutureOut: { value: "opaque", secret: true },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A progress.json as installers before #576 / #656 wrote it. */
|
|
59
|
+
const legacyProgress = {
|
|
60
|
+
lastCompletedStep: "pulumi-up-acme",
|
|
61
|
+
updatedAt: "2026-10-01T00:00:00.000Z",
|
|
62
|
+
steps: {
|
|
63
|
+
"project-acme": { existed: true },
|
|
64
|
+
"sa-acme": { created: true, saEmail: "caelo-provisioner@acme.iam.gserviceaccount.com" },
|
|
65
|
+
"sa-key-acme": { path: "/home/op/.caelo-gcp-acme/secrets/sa-key.json" },
|
|
66
|
+
"pulumi-up-acme": {
|
|
67
|
+
outputs: {
|
|
68
|
+
adminDatabaseUrlOut: legacyAdminUrl,
|
|
69
|
+
publicDatabaseUrlOut: legacyPublicUrl,
|
|
70
|
+
bootstrapUrlOut: setupLink,
|
|
71
|
+
providerOut: "gcp",
|
|
72
|
+
lbIpOut: "203.0.113.7",
|
|
73
|
+
adminDomainOut: "admin.acme.test",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe("findSecrets", () => {
|
|
80
|
+
it("flags URLs carrying a password and names the database user", () => {
|
|
81
|
+
const found = findSecrets(legacyProgress);
|
|
82
|
+
expect(found).toContainEqual({
|
|
83
|
+
path: "steps.pulumi-up-acme.outputs.adminDatabaseUrlOut",
|
|
84
|
+
kind: "credential-url",
|
|
85
|
+
user: "admin_role",
|
|
86
|
+
});
|
|
87
|
+
expect(found).toContainEqual({
|
|
88
|
+
path: "steps.pulumi-up-acme.outputs.publicDatabaseUrlOut",
|
|
89
|
+
kind: "credential-url",
|
|
90
|
+
user: "public_role",
|
|
91
|
+
});
|
|
92
|
+
expect(found).toContainEqual({
|
|
93
|
+
path: "steps.pulumi-up-acme.outputs.bootstrapUrlOut",
|
|
94
|
+
kind: "token-url",
|
|
95
|
+
});
|
|
96
|
+
// Findings name where, never what.
|
|
97
|
+
expect(JSON.stringify(found)).not.toContain(FAKE_PW);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not flag the password-less database URLs the stacks export today", () => {
|
|
101
|
+
expect(findSecrets(databaseUrls("10.1.2.3"))).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags secret-named keys holding a value, not markers or look-alikes", () => {
|
|
105
|
+
expect(findSecrets({ postgresPassword: "x" })).toEqual([
|
|
106
|
+
{ path: "postgresPassword", kind: "secret-key" },
|
|
107
|
+
]);
|
|
108
|
+
expect(findSecrets({ caeloSecretKekOut: "x", anthropic_api_key: "y" })).toHaveLength(2);
|
|
109
|
+
expect(findSecrets({ bootstrapTokenExpiresAtOut: "2026-10-13", hasToken: false })).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("checkpointableOutputs", () => {
|
|
114
|
+
it("drops secret-flagged outputs and the setup link, keeps what resume and DNS need", () => {
|
|
115
|
+
const kept = checkpointableOutputs(stackOutputs);
|
|
116
|
+
expect(Object.keys(kept)).not.toContain("someFutureOut");
|
|
117
|
+
expect(Object.keys(kept)).not.toContain("bootstrapUrlOut");
|
|
118
|
+
expect(kept.dnsRecordsRequiredOut).toEqual(stackOutputs.dnsRecordsRequiredOut.value);
|
|
119
|
+
expect(kept.adminDomainOut).toBe("admin.acme.test");
|
|
120
|
+
expect(kept.bootstrapTokenExpiresAtOut).toBe("2026-10-13T00:00:00.000Z");
|
|
121
|
+
expect(findSecrets(kept)).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("drops a database URL that carries a password even when Pulumi did not mark it secret", () => {
|
|
125
|
+
const kept = checkpointableOutputs({
|
|
126
|
+
adminDatabaseUrlOut: { value: legacyAdminUrl, secret: false },
|
|
127
|
+
});
|
|
128
|
+
expect(kept).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("checkpoint guard (regression #656)", () => {
|
|
133
|
+
it("refuses a payload with a credential URL without echoing the secret", () => {
|
|
134
|
+
let message = "";
|
|
135
|
+
try {
|
|
136
|
+
assertNoSecrets({ outputs: { adminDatabaseUrlOut: legacyAdminUrl } }, "progress.json");
|
|
137
|
+
} catch (e) {
|
|
138
|
+
message = e instanceof Error ? e.message : String(e);
|
|
139
|
+
}
|
|
140
|
+
expect(message).toContain("outputs.adminDatabaseUrlOut: credential-url");
|
|
141
|
+
expect(message).not.toContain(FAKE_PW);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("accepts every payload shape the wizard checkpoints", () => {
|
|
145
|
+
const payloads = [
|
|
146
|
+
{ existed: true },
|
|
147
|
+
{ created: true },
|
|
148
|
+
{ billingAccountId: "000000-000000-000000" },
|
|
149
|
+
{ count: 12 },
|
|
150
|
+
{ existed: true, saEmail: "sa@acme.iam.gserviceaccount.com" },
|
|
151
|
+
{ granted: ["roles/run.admin"] },
|
|
152
|
+
{ path: "/home/op/.caelo-gcp-acme/secrets/sa-key.json" },
|
|
153
|
+
{ outputs: checkpointableOutputs(stackOutputs) },
|
|
154
|
+
{ skipped: "exists" },
|
|
155
|
+
{ bucket: "caelo-production-static-acme" },
|
|
156
|
+
{ adapter: "cloudflare" },
|
|
157
|
+
];
|
|
158
|
+
for (const p of payloads) expect(() => assertNoSecrets(p, "progress.json")).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("every markStepDone call site in the CLI uses a reviewed payload key", () => {
|
|
162
|
+
// A new checkpoint payload must be added here on purpose, after checking
|
|
163
|
+
// it holds no secret. `outputs` must come from pulumiUpGcp, which returns
|
|
164
|
+
// only checkpointableOutputs().
|
|
165
|
+
const reviewed = new Set([
|
|
166
|
+
"existed",
|
|
167
|
+
"created",
|
|
168
|
+
"billingAccountId",
|
|
169
|
+
"count",
|
|
170
|
+
"saEmail",
|
|
171
|
+
"granted",
|
|
172
|
+
"path",
|
|
173
|
+
"outputs",
|
|
174
|
+
"skipped",
|
|
175
|
+
"bucket",
|
|
176
|
+
"adapter",
|
|
177
|
+
]);
|
|
178
|
+
const files = [
|
|
179
|
+
...readdirSync(import.meta.dir).map((f) => join(import.meta.dir, f)),
|
|
180
|
+
...readdirSync(join(import.meta.dir, "wizards")).map((f) =>
|
|
181
|
+
join(import.meta.dir, "wizards", f),
|
|
182
|
+
),
|
|
183
|
+
].filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
|
|
184
|
+
const keys: string[] = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const src = readFileSync(file, "utf8");
|
|
187
|
+
for (const m of src.matchAll(/markStepDone\([^,]+,[^,]+,\s*\{([^}]*)\}/g)) {
|
|
188
|
+
for (const part of (m[1] ?? "").split(",")) {
|
|
189
|
+
const key = part.split(":")[0]?.trim();
|
|
190
|
+
if (key) keys.push(key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
195
|
+
expect(keys.filter((k) => !reviewed.has(k))).toEqual([]);
|
|
196
|
+
const gcp = readFileSync(join(import.meta.dir, "wizards", "gcp.ts"), "utf8");
|
|
197
|
+
expect(gcp).toContain("markStepDone(installId, stepName, { outputs: result.outputs })");
|
|
198
|
+
const up = readFileSync(join(import.meta.dir, "wizards", "gcp-pulumi.ts"), "utf8");
|
|
199
|
+
expect(up).toContain("outputs: checkpointableOutputs(result.outputs)");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("checkpoints of projects with secret-sounding ids (codex review)", () => {
|
|
204
|
+
it("treats step names as identifiers, not secret fields", () => {
|
|
205
|
+
const checkpoint = {
|
|
206
|
+
lastCompletedStep: "project-acme-token",
|
|
207
|
+
updatedAt: "t",
|
|
208
|
+
steps: { "project-acme-token": { existed: true }, "billing-acme-secret": { count: 1 } },
|
|
209
|
+
};
|
|
210
|
+
expect(findCheckpointSecrets(checkpoint)).toEqual([]);
|
|
211
|
+
expect(() => assertNoCheckpointSecrets(checkpoint, "progress.json")).not.toThrow();
|
|
212
|
+
// Payloads under such steps are still inspected.
|
|
213
|
+
expect(
|
|
214
|
+
findCheckpointSecrets({ steps: { "project-acme-token": { url: legacyAdminUrl } } }),
|
|
215
|
+
).toEqual([
|
|
216
|
+
{ path: "steps.project-acme-token.url", kind: "credential-url", user: "admin_role" },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("scrubProgressFile", () => {
|
|
222
|
+
const withFile = (contents: unknown, run: (path: string) => void) => {
|
|
223
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-scrub-"));
|
|
224
|
+
try {
|
|
225
|
+
const path = join(dir, "progress.json");
|
|
226
|
+
writeFileSync(path, JSON.stringify(contents), { mode: 0o644 });
|
|
227
|
+
run(path);
|
|
228
|
+
} finally {
|
|
229
|
+
rmSync(dir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
it("removes the secrets, keeps every completed step, and leaves the file 0600", () => {
|
|
234
|
+
withFile(legacyProgress, (path) => {
|
|
235
|
+
const { removed } = scrubProgressFile(path);
|
|
236
|
+
expect(removed.map((f) => f.kind).sort()).toEqual([
|
|
237
|
+
"credential-url",
|
|
238
|
+
"credential-url",
|
|
239
|
+
"token-url",
|
|
240
|
+
]);
|
|
241
|
+
const text = readFileSync(path, "utf8");
|
|
242
|
+
expect(text).not.toContain(FAKE_PW);
|
|
243
|
+
expect(text).not.toContain("token=");
|
|
244
|
+
const after = JSON.parse(text);
|
|
245
|
+
// Wizard re-runs resume: every step is still recorded as done.
|
|
246
|
+
expect(Object.keys(after.steps)).toEqual(Object.keys(legacyProgress.steps));
|
|
247
|
+
expect(after.lastCompletedStep).toBe("pulumi-up-acme");
|
|
248
|
+
expect(after.steps["pulumi-up-acme"].outputs).toEqual({
|
|
249
|
+
providerOut: "gcp",
|
|
250
|
+
lbIpOut: "203.0.113.7",
|
|
251
|
+
adminDomainOut: "admin.acme.test",
|
|
252
|
+
});
|
|
253
|
+
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
254
|
+
expect(readdirSync(join(path, "..")).filter((f) => f.includes(".tmp-"))).toEqual([]);
|
|
255
|
+
// Idempotent.
|
|
256
|
+
expect(scrubProgressFile(path).removed).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps the completed-step markers of a project whose id sounds secret", () => {
|
|
261
|
+
const cp = {
|
|
262
|
+
lastCompletedStep: "pulumi-up-acme-token",
|
|
263
|
+
updatedAt: "t",
|
|
264
|
+
steps: {
|
|
265
|
+
"project-acme-token": { existed: true },
|
|
266
|
+
"pulumi-up-acme-token": { outputs: { adminDatabaseUrlOut: legacyAdminUrl, lbIpOut: "1" } },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
withFile(cp, (path) => {
|
|
270
|
+
expect(scrubProgressFile(path).removed).toHaveLength(1);
|
|
271
|
+
const after = JSON.parse(readFileSync(path, "utf8"));
|
|
272
|
+
expect(after.steps).toEqual({
|
|
273
|
+
"project-acme-token": { existed: true },
|
|
274
|
+
"pulumi-up-acme-token": { outputs: { lbIpOut: "1" } },
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("leaves a clean file untouched and a missing one alone", () => {
|
|
280
|
+
withFile(
|
|
281
|
+
{ lastCompletedStep: "x", steps: { x: { created: true } }, updatedAt: "t" },
|
|
282
|
+
(path) => {
|
|
283
|
+
const before = readFileSync(path, "utf8");
|
|
284
|
+
expect(scrubProgressFile(path).removed).toEqual([]);
|
|
285
|
+
expect(readFileSync(path, "utf8")).toBe(before);
|
|
286
|
+
expect(scrubProgressFile(`${path}.missing`).removed).toEqual([]);
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("rotationHints", () => {
|
|
293
|
+
it("names the rotate-secret command for each leaked role password", () => {
|
|
294
|
+
const hints = rotationHints(
|
|
295
|
+
[
|
|
296
|
+
{ path: "a", kind: "credential-url", user: "admin_role" },
|
|
297
|
+
{ path: "b", kind: "credential-url", user: "public_role" },
|
|
298
|
+
{ path: "c", kind: "credential-url", user: "gateway_role" },
|
|
299
|
+
{ path: "d", kind: "credential-url", user: "admin_role" },
|
|
300
|
+
],
|
|
301
|
+
"gcp-acme",
|
|
302
|
+
);
|
|
303
|
+
expect(hints).toEqual([
|
|
304
|
+
`admin_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret postgres-password --install gcp-acme`,
|
|
305
|
+
`public_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret public-role-password --install gcp-acme`,
|
|
306
|
+
`gateway_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret gateway-role-password --install gcp-acme`,
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("says the setup link needs no rotation", () => {
|
|
311
|
+
const [hint] = rotationHints([{ path: "a", kind: "token-url" }], "gcp-acme");
|
|
312
|
+
expect(hint).toContain("nothing to rotate");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("redactCredentials", () => {
|
|
317
|
+
it("masks URL passwords and leaves the rest readable", () => {
|
|
318
|
+
const out = redactCredentials(`connect failed: ${legacyAdminUrl} (timeout)`);
|
|
319
|
+
expect(out).not.toContain(FAKE_PW);
|
|
320
|
+
expect(out).toContain("postgresql://admin_role:***@10.1.2.3:5432/cms_admin");
|
|
321
|
+
expect(redactCredentials("https://admin.acme.test/x")).toBe("https://admin.acme.test/x");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("install dir end to end (regression #656)", () => {
|
|
326
|
+
it("a wizard's checkpoints leave no secret outside secrets/; legacy files are scrubbed; secrets are refused", () => {
|
|
327
|
+
// installRoot() resolves ~ via os.homedir(), read once at process start —
|
|
328
|
+
// drive install-state in a child process with its own HOME.
|
|
329
|
+
const home = mkdtempSync(join(tmpdir(), "caelo-install-656-"));
|
|
330
|
+
const installId = "gcp-acme";
|
|
331
|
+
const root = join(home, `.caelo-${installId}`);
|
|
332
|
+
try {
|
|
333
|
+
mkdirSync(root, { recursive: true });
|
|
334
|
+
writeFileSync(join(root, "progress.json"), JSON.stringify(legacyProgress), {
|
|
335
|
+
mode: 0o600,
|
|
336
|
+
});
|
|
337
|
+
const mod = resolve(import.meta.dir, "install-state.ts");
|
|
338
|
+
const sec = resolve(import.meta.dir, "checkpoint-secrets.ts");
|
|
339
|
+
const script = `
|
|
340
|
+
const m = await import(${JSON.stringify(mod)});
|
|
341
|
+
const s = await import(${JSON.stringify(sec)});
|
|
342
|
+
const id = ${JSON.stringify(installId)};
|
|
343
|
+
const lines = m.scrubInstallProgress(id);
|
|
344
|
+
m.markStepDone(id, "sa-acme", { created: true, saEmail: "sa@acme.iam.gserviceaccount.com" });
|
|
345
|
+
m.markStepDone(id, "pulumi-up-acme", { outputs: s.checkpointableOutputs(${JSON.stringify(stackOutputs)}) });
|
|
346
|
+
let refused = "";
|
|
347
|
+
try {
|
|
348
|
+
m.markStepDone(id, "bad", { url: ${JSON.stringify(legacyAdminUrl)} });
|
|
349
|
+
} catch (e) { refused = e.message; }
|
|
350
|
+
console.log(JSON.stringify({ lines, refused, done: m.isStepDone(id, "project-acme") }));
|
|
351
|
+
`;
|
|
352
|
+
const child = Bun.spawnSync([process.execPath, "-e", script], {
|
|
353
|
+
env: { ...process.env, HOME: home },
|
|
354
|
+
stderr: "pipe",
|
|
355
|
+
});
|
|
356
|
+
expect(new TextDecoder().decode(child.stderr)).toBe("");
|
|
357
|
+
expect(child.exitCode).toBe(0);
|
|
358
|
+
const result = JSON.parse(new TextDecoder().decode(child.stdout));
|
|
359
|
+
expect(result.done).toBe(true);
|
|
360
|
+
expect(result.refused).toContain("credential-url");
|
|
361
|
+
expect(result.refused).not.toContain(FAKE_PW);
|
|
362
|
+
expect(result.lines.join("\n")).toContain(
|
|
363
|
+
"rotate-secret postgres-password --install gcp-acme",
|
|
364
|
+
);
|
|
365
|
+
expect(result.lines.join("\n")).toContain(
|
|
366
|
+
"rotate-secret public-role-password --install gcp-acme",
|
|
367
|
+
);
|
|
368
|
+
// Nothing under the install dir but secrets/ holds secret material.
|
|
369
|
+
for (const f of readdirSync(root)) {
|
|
370
|
+
if (f === "secrets" || f === "state") continue;
|
|
371
|
+
const text = readFileSync(join(root, f), "utf8");
|
|
372
|
+
expect(findSecrets(JSON.parse(text))).toEqual([]);
|
|
373
|
+
expect(text).not.toContain(FAKE_PW);
|
|
374
|
+
}
|
|
375
|
+
expect(statSync(join(root, "progress.json")).mode & 0o777).toBe(0o600);
|
|
376
|
+
} finally {
|
|
377
|
+
rmSync(home, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
}, 30_000);
|
|
380
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps secret material out of the files the CLI writes under
|
|
5
|
+
* `~/.caelo-<install-id>/` (#656).
|
|
6
|
+
*
|
|
7
|
+
* Only `secrets/` may hold secrets (CLAUDE.md §7, §11.C); `progress.json`
|
|
8
|
+
* and `install.json` are plain bookkeeping that tooling, backups, sync
|
|
9
|
+
* clients and agents read. Installs from before #576 checkpointed the full
|
|
10
|
+
* Pulumi outputs, whose database URLs carried admin_role's and
|
|
11
|
+
* public_role's passwords, and every install so far checkpointed the Owner
|
|
12
|
+
* setup link with its token. This module
|
|
13
|
+
*
|
|
14
|
+
* - finds secret material in a JSON value ({@link findSecrets}), which
|
|
15
|
+
* `markStepDone` / `writeMetadata` use to refuse writing it;
|
|
16
|
+
* - removes it from an existing checkpoint ({@link scrubProgressFile}),
|
|
17
|
+
* which `upgrade` and wizard re-runs call, and names the secrets to
|
|
18
|
+
* rotate ({@link rotationHints});
|
|
19
|
+
* - masks credentials in text the CLI prints ({@link redactCredentials}).
|
|
20
|
+
*
|
|
21
|
+
* Detection is by shape (a URL with a password, a URL with a token-like
|
|
22
|
+
* query parameter) and by key name (password, secret, token, ...), so a
|
|
23
|
+
* future secret output is caught without being listed here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { selfCommand } from "./self-command.js";
|
|
28
|
+
import { DATABASE_ROLE_SECRET, type DatabaseRole } from "./stack-contract.js";
|
|
29
|
+
|
|
30
|
+
/** One place a JSON value holds secret material. Never carries the secret itself. */
|
|
31
|
+
export interface SecretFinding {
|
|
32
|
+
/** Dotted path to the offending value, e.g. `steps.pulumi-up-acme.outputs.adminDatabaseUrlOut`. */
|
|
33
|
+
readonly path: string;
|
|
34
|
+
/**
|
|
35
|
+
* `credential-url`: a URL whose user-info carries a password.
|
|
36
|
+
* `token-url`: a URL with a token / secret / password query parameter.
|
|
37
|
+
* `secret-key`: a key whose name marks its value as secret.
|
|
38
|
+
*/
|
|
39
|
+
readonly kind: "credential-url" | "token-url" | "secret-key";
|
|
40
|
+
/** For a credential URL: the user whose password it carries. */
|
|
41
|
+
readonly user?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `scheme://user:password@` — the user-info of a URL that carries a password. */
|
|
45
|
+
const CREDENTIAL_URL = /[a-z][a-z0-9+.-]{0,31}:\/\/([^\s:@/]*):([^\s@/]+)@/gi;
|
|
46
|
+
|
|
47
|
+
/** A URL query parameter that carries a credential (the Owner setup link's `?token=`). */
|
|
48
|
+
const TOKEN_PARAM =
|
|
49
|
+
/[?&](?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|sig|signature)=[^&\s#]+/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Key names whose value is a secret, after lower-casing, dropping non
|
|
53
|
+
* alphanumerics and a Pulumi `Out` suffix: `postgresPassword`,
|
|
54
|
+
* `caeloSecretKek`, `bootstrap_token`, `anthropicApiKey`.
|
|
55
|
+
*/
|
|
56
|
+
const SECRET_KEY = /(password|passwd|passphrase|secret|secretkek|kek|token|apikey|privatekey)$/;
|
|
57
|
+
|
|
58
|
+
function isSecretKey(key: string): boolean {
|
|
59
|
+
const k = key
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^a-z0-9]/g, "")
|
|
62
|
+
.replace(/out$/, "");
|
|
63
|
+
return SECRET_KEY.test(k);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function urlUser(raw: string): string {
|
|
67
|
+
try {
|
|
68
|
+
return decodeURIComponent(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stringFindings(value: string, path: string): SecretFinding[] {
|
|
75
|
+
const found: SecretFinding[] = [];
|
|
76
|
+
for (const m of value.matchAll(CREDENTIAL_URL)) {
|
|
77
|
+
found.push({ path, kind: "credential-url", user: urlUser(m[1] ?? "") });
|
|
78
|
+
}
|
|
79
|
+
if (found.length === 0 && /:\/\//.test(value) && TOKEN_PARAM.test(value)) {
|
|
80
|
+
found.push({ path, kind: "token-url" });
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every place `value` holds secret material.
|
|
87
|
+
*
|
|
88
|
+
* @param value any JSON-shaped value (a checkpoint payload, install.json)
|
|
89
|
+
* @param path the path prefix to report findings under
|
|
90
|
+
*/
|
|
91
|
+
export function findSecrets(value: unknown, path = ""): SecretFinding[] {
|
|
92
|
+
if (typeof value === "string") return stringFindings(value, path || "(value)");
|
|
93
|
+
const join = (k: string | number) => (path ? `${path}.${k}` : String(k));
|
|
94
|
+
if (Array.isArray(value)) return value.flatMap((v, i) => findSecrets(v, join(i)));
|
|
95
|
+
if (value !== null && typeof value === "object") {
|
|
96
|
+
return Object.entries(value).flatMap(([k, v]) => {
|
|
97
|
+
// A secret-named key is only a finding when it holds something; a
|
|
98
|
+
// null / false / 0 marker is not secret material.
|
|
99
|
+
if (isSecretKey(k) && v !== null && v !== undefined && v !== "" && typeof v !== "boolean") {
|
|
100
|
+
return [{ path: join(k), kind: "secret-key" as const }];
|
|
101
|
+
}
|
|
102
|
+
return findSecrets(v, join(k));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Throw when `value` holds secret material. The message names the paths,
|
|
110
|
+
* never the values, so the error itself cannot leak them.
|
|
111
|
+
*
|
|
112
|
+
* @param what what was about to be written, for the message
|
|
113
|
+
*/
|
|
114
|
+
export function assertNoSecrets(value: unknown, what: string): void {
|
|
115
|
+
refuseFindings(findSecrets(value), what);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** {@link assertNoSecrets} for a whole checkpoint (see {@link findCheckpointSecrets}). */
|
|
119
|
+
export function assertNoCheckpointSecrets(checkpoint: unknown, what: string): void {
|
|
120
|
+
refuseFindings(findCheckpointSecrets(checkpoint), what);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function refuseFindings(found: readonly SecretFinding[], what: string): void {
|
|
124
|
+
if (found.length === 0) return;
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Refusing to write secret material to ${what} (${found.map((f) => `${f.path}: ${f.kind}`).join(", ")}). ` +
|
|
127
|
+
"Secrets belong in Secret Manager or the install's secrets/ directory; read them at the moment of use instead (#656).",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
132
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* {@link findSecrets} for a progress.json checkpoint. Its step names are
|
|
137
|
+
* derived from operator input (`project-<project id>`), so they are
|
|
138
|
+
* identifiers, not field names: a project called `acme-token` must not read
|
|
139
|
+
* as a secret. Only the step payloads (and the other top-level fields) are
|
|
140
|
+
* inspected.
|
|
141
|
+
*/
|
|
142
|
+
export function findCheckpointSecrets(checkpoint: unknown): SecretFinding[] {
|
|
143
|
+
if (!isRecord(checkpoint)) return findSecrets(checkpoint);
|
|
144
|
+
const { steps, ...rest } = checkpoint;
|
|
145
|
+
const inSteps = isRecord(steps)
|
|
146
|
+
? Object.entries(steps).flatMap(([name, payload]) => findSecrets(payload, `steps.${name}`))
|
|
147
|
+
: findSecrets(steps, "steps");
|
|
148
|
+
return [...findSecrets(rest), ...inSteps];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** {@link withoutSecrets} for a checkpoint: every step is kept, its payload cleaned. */
|
|
152
|
+
function checkpointWithoutSecrets(checkpoint: unknown): unknown {
|
|
153
|
+
if (!isRecord(checkpoint)) return withoutSecrets(checkpoint);
|
|
154
|
+
const { steps, ...rest } = checkpoint;
|
|
155
|
+
return {
|
|
156
|
+
...(withoutSecrets(rest) as Record<string, unknown>),
|
|
157
|
+
steps: isRecord(steps)
|
|
158
|
+
? Object.fromEntries(Object.entries(steps).map(([n, p]) => [n, withoutSecrets(p)]))
|
|
159
|
+
: withoutSecrets(steps),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `value` without its secret material: secret-named keys and strings that
|
|
165
|
+
* hold a credential or token URL are dropped. Everything else is kept.
|
|
166
|
+
*/
|
|
167
|
+
export function withoutSecrets(value: unknown): unknown {
|
|
168
|
+
if (typeof value === "string") return value;
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
return value
|
|
171
|
+
.filter((v) => typeof v !== "string" || findSecrets(v).length === 0)
|
|
172
|
+
.map(withoutSecrets);
|
|
173
|
+
}
|
|
174
|
+
if (value !== null && typeof value === "object") {
|
|
175
|
+
const out: Record<string, unknown> = {};
|
|
176
|
+
for (const [k, v] of Object.entries(value)) {
|
|
177
|
+
if (findSecrets({ [k]: v }).length > 0 && (isSecretKey(k) || typeof v === "string")) continue;
|
|
178
|
+
out[k] = withoutSecrets(v);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The Pulumi outputs worth checkpointing: everything except outputs Pulumi
|
|
187
|
+
* marks secret and anything else that holds secret material (the Owner
|
|
188
|
+
* setup link). Nothing reads secrets back from the checkpoint; code that
|
|
189
|
+
* needs one reads the stack (`readStackOutput`) when it needs it.
|
|
190
|
+
*
|
|
191
|
+
* @param outputs the stack outputs as the Automation API returns them
|
|
192
|
+
*/
|
|
193
|
+
export function checkpointableOutputs(
|
|
194
|
+
outputs: Readonly<Record<string, { value: unknown; secret?: boolean }>>,
|
|
195
|
+
): Record<string, unknown> {
|
|
196
|
+
const kept: Record<string, unknown> = {};
|
|
197
|
+
for (const [k, o] of Object.entries(outputs)) {
|
|
198
|
+
if (o.secret) continue;
|
|
199
|
+
if (findSecrets({ [k]: o.value }).length > 0) continue;
|
|
200
|
+
kept[k] = o.value;
|
|
201
|
+
}
|
|
202
|
+
return kept;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Outcome of {@link scrubProgressFile}. */
|
|
206
|
+
export interface ScrubReport {
|
|
207
|
+
/** What was removed (paths + kinds, never values). Empty when the file was clean or absent. */
|
|
208
|
+
readonly removed: readonly SecretFinding[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Remove secret material from a `progress.json` in place. The rewrite is
|
|
213
|
+
* atomic (temp file in the same directory, then rename) and the result is
|
|
214
|
+
* mode 0600, so an interrupted scrub never leaves a truncated checkpoint
|
|
215
|
+
* and wizard re-runs still find every completed step.
|
|
216
|
+
*
|
|
217
|
+
* @param path the checkpoint file; a missing file is a no-op
|
|
218
|
+
*/
|
|
219
|
+
export function scrubProgressFile(path: string): ScrubReport {
|
|
220
|
+
if (!existsSync(path)) return { removed: [] };
|
|
221
|
+
const checkpoint: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
222
|
+
const removed = findCheckpointSecrets(checkpoint);
|
|
223
|
+
if (removed.length === 0) return { removed };
|
|
224
|
+
writeJsonAtomic(path, checkpointWithoutSecrets(checkpoint));
|
|
225
|
+
return { removed };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Write `value` as JSON to `path` atomically with mode 0600: a temp file in
|
|
230
|
+
* the same directory (so the rename stays on one filesystem), then rename.
|
|
231
|
+
*/
|
|
232
|
+
export function writeJsonAtomic(path: string, value: unknown): void {
|
|
233
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
236
|
+
renameSync(tmp, path);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
rmSync(tmp, { force: true });
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const ROLE_BY_USER = DATABASE_ROLE_SECRET as Readonly<Record<string, string | undefined>>;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What the operator should do about secrets a scrub removed: one line per
|
|
247
|
+
* affected secret, with the exact command where one exists.
|
|
248
|
+
*
|
|
249
|
+
* @param removed the scrub's findings
|
|
250
|
+
* @param installId the install, named in each command so it acts on the right one
|
|
251
|
+
*/
|
|
252
|
+
export function rotationHints(removed: readonly SecretFinding[], installId: string): string[] {
|
|
253
|
+
const rotate = (secret: string) => selfCommand("rotate-secret", secret, "--install", installId);
|
|
254
|
+
const hints = new Set<string>();
|
|
255
|
+
for (const f of removed) {
|
|
256
|
+
if (f.kind === "credential-url") {
|
|
257
|
+
const secret = f.user ? ROLE_BY_USER[f.user as DatabaseRole] : undefined;
|
|
258
|
+
hints.add(
|
|
259
|
+
secret
|
|
260
|
+
? `${f.user}'s password was stored in progress.json. Rotate it: ${rotate(secret)}`
|
|
261
|
+
: `A password for database user "${f.user ?? "?"}" was stored in progress.json (${f.path}); change it.`,
|
|
262
|
+
);
|
|
263
|
+
} else if (f.kind === "token-url") {
|
|
264
|
+
hints.add(
|
|
265
|
+
"The Owner setup link (with its one-time token) was stored in progress.json. It expires 24 hours after it was issued and stops working once the Owner account exists; nothing to rotate.",
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
hints.add(
|
|
269
|
+
`A secret value was stored in progress.json (${f.path}). Rotate whatever it belongs to.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [...hints];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `text` with every URL password masked (`postgresql://admin_role:***@`),
|
|
278
|
+
* for anything the CLI prints that could carry one (errors from Pulumi or
|
|
279
|
+
* gcloud, for instance). Same masking as the API gateway's error log.
|
|
280
|
+
*/
|
|
281
|
+
export function redactCredentials(text: string): string {
|
|
282
|
+
return text.replace(/([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]*):[^\s@/]+@/gi, "$1:***@");
|
|
283
|
+
}
|