diff --git a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts index b6db0b767f4..110c23bb764 100644 --- a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts +++ b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts @@ -205,6 +205,17 @@ vi.mock("ai", () => ({ generateText: vi.fn(), })); +vi.mock("@dokploy/server/services/known-secrets", async () => { + const { buildKnownSecretMatcher } = await import( + "@dokploy/server/utils/security/secret-scrub" + ); + return { + getKnownSecretMatcher: async () => + buildKnownSecretMatcher(["Zx9-sentinel-Value42"]), + resetKnownSecretMatcherCache: () => {}, + }; +}); + vi.mock("@dokploy/server/db", () => ({ db: { query: {}, @@ -539,6 +550,36 @@ describe("deploy target placement ownership boundary", () => { ); }); + it("sends logs to the AI provider without secret values", async () => { + vi.mocked(selectAIProvider).mockReturnValueOnce( + vi.fn(() => "model") as never, + ); + vi.mocked(generateText).mockResolvedValueOnce({ + text: "ok", + } as Awaited>); + aiServiceMocks.getAiSettingById.mockResolvedValueOnce({ + aiId: "ai-1", + apiKey: "secret", + apiUrl: "https://api.openai.com/v1", + isEnabled: true, + model: "gpt-4o-mini", + name: "OpenAI", + organizationId: "org-1", + }); + + await aiRouter.createCaller(createContext("admin")).analyzeLogs({ + aiId: "ai-1", + context: "runtime", + logs: "connect failed with Zx9-sentinel-Value42\nDB_PASS=hunter2-hunter2\nlistening on 3000", + }); + + const prompt = vi.mocked(generateText).mock.calls.at(-1)?.[0].prompt; + expect(prompt).toContain("listening on 3000"); + expect(prompt).toContain("connect failed with __DOKPLOY_REDACTED_SECRET__"); + expect(prompt).not.toContain("Zx9-sentinel-Value42"); + expect(prompt).not.toContain("hunter2-hunter2"); + }); + it("denies AI suggestions for inaccessible servers before server metadata reaches AI service", async () => { await expect( aiRouter.createCaller(createContext()).suggest({ diff --git a/apps/dokploy/__test__/security/api-key-output-guard.test.ts b/apps/dokploy/__test__/security/api-key-output-guard.test.ts new file mode 100644 index 00000000000..68a2381cfa0 --- /dev/null +++ b/apps/dokploy/__test__/security/api-key-output-guard.test.ts @@ -0,0 +1,434 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +const SENTINEL = "Zx9-sentinel-Value42"; + +const mocks = vi.hoisted(() => ({ + getKnownSecretMatcher: vi.fn(), + resetKnownSecretMatcherCache: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); + +vi.mock("@dokploy/server/services/known-secrets", () => ({ + getKnownSecretMatcher: mocks.getKnownSecretMatcher, + resetKnownSecretMatcherCache: mocks.resetKnownSecretMatcherCache, +})); + +vi.mock("@dokploy/server/services/super-password", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/super-password") + >()), + checkSuperSessionAccess: vi.fn(async () => null), +})); + +const { buildKnownSecretMatcher } = await import( + "@dokploy/server/utils/security/secret-scrub" +); +const { createTRPCRouter, protectedProcedure } = await import( + "@/server/api/trpc" +); +const { isProcedureDeniedForApiKey } = await import( + "@/server/api/utils/api-key-output-guard" +); +const { PROCEDURE_ACCESS } = await import( + "@/server/api/utils/procedure-access" +); + +const handled = vi.fn(); + +const service = { + applicationId: "app-1", + name: "api", + env: `API_TOKEN=${SENTINEL}`, + environment: { name: "production", env: `SHARED=${SENTINEL}` }, +}; + +const secretReader = protectedProcedure.mutation(() => { + handled(); + return { env: SENTINEL }; +}); + +const storedCompose = [ + "services:", + " api:", + " image: api:1", + " environment:", + ` FAVOURITE_COLOUR: ${SENTINEL}`, + " NEUTRAL_NAME: plain-neutral-value", + "", +].join("\n"); +const storedMount = `user=app\npassword=${SENTINEL}\nport=8080\n`; +const saved = vi.fn(); + +const { restoreApiKeyText } = await import( + "@/server/api/utils/api-key-output-guard" +); + +const testRouter = createTRPCRouter({ + compose: createTRPCRouter({ + one: protectedProcedure.query(() => ({ + composeId: "compose-1", + composeFile: storedCompose, + })), + getConvertedCompose: protectedProcedure.query(() => storedCompose), + update: protectedProcedure + .input( + z.object({ + composeFile: z.string(), + description: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + saved( + await restoreApiKeyText( + ctx.session, + "compose", + input.composeFile, + storedCompose, + ), + ); + return true; + }), + }), + mounts: createTRPCRouter({ + one: protectedProcedure.query(() => ({ + mountId: "m-1", + content: storedMount, + })), + update: protectedProcedure + .input(z.object({ content: z.string() })) + .mutation(async ({ input, ctx }) => { + saved( + await restoreApiKeyText( + ctx.session, + "file", + input.content, + storedMount, + ), + ); + return true; + }), + }), + application: createTRPCRouter({ + one: protectedProcedure.query(() => service), + readLogs: protectedProcedure.query( + () => `boot ok\nusing token ${SENTINEL}\nDB_PASS=hunter2-hunter2`, + ), + update: protectedProcedure + .input(z.object({ command: z.string(), env: z.string().optional() })) + .mutation(({ input }) => { + handled(input); + return true; + }), + fail: protectedProcedure.query(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `docker login failed for ${SENTINEL}`, + }); + }), + deployWithLogs: protectedProcedure.subscription(async function* () { + yield `pulling with ${SENTINEL}`; + yield "done"; + }), + revision: protectedProcedure.query(() => ({ + revision: "env:Abc123-revision", + expectedRevision: "env:Abc123-revision", + })), + revealEnvironment: secretReader, + }), + postgres: createTRPCRouter({ revealEnvironment: secretReader }), + docker: createTRPCRouter({ + getConfig: secretReader, + readContainerFile: secretReader, + }), + dockerVolume: createTRPCRouter({ readVolumeFile: secretReader }), + settings: createTRPCRouter({ + readTraefikEnv: secretReader, + getOpenApiDocument: protectedProcedure.query(() => ({ + properties: { password: { type: "string" }, env: { type: "string" } }, + })), + }), + user: createTRPCRouter({ revealApiKey: secretReader }), +}); + +const owner = { + id: "user-1", + email: "owner@example.test", + role: "owner" as const, + ownerId: "user-1", +}; + +const callerFor = (authMethod: "session" | "api-key") => + testRouter.createCaller({ + db: {}, + req: { headers: {} }, + res: {}, + user: owner, + session: { + id: "session-1", + userId: owner.id, + activeOrganizationId: "org-1", + authMethod, + }, + } as never); + +const apiKey = () => callerFor("api-key"); +const browser = () => callerFor("session"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getKnownSecretMatcher.mockResolvedValue( + buildKnownSecretMatcher([SENTINEL]), + ); +}); + +describe("API key responses", () => { + it("never carry secret fields, even for the owner", async () => { + const result = await apiKey().application.one(); + + expect(result).toEqual({ + applicationId: "app-1", + name: "api", + env: R, + environment: { name: "production", env: R }, + }); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + }); + + it("leave browser sessions untouched", async () => { + await expect(browser().application.one()).resolves.toEqual(service); + await expect(browser().application.readLogs()).resolves.toContain(SENTINEL); + expect(mocks.getKnownSecretMatcher).not.toHaveBeenCalled(); + }); + + it("scrub known values and credential-looking text out of logs", async () => { + await expect(apiKey().application.readLogs()).resolves.toBe( + `boot ok\nusing token ${R}\nDB_PASS=${R}`, + ); + }); + + it("scrub error messages", async () => { + const error = await apiKey() + .application.fail() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + expect((error as TRPCError).message).toBe(`docker login failed for ${R}`); + }); + + it("scrub every chunk of a subscription", async () => { + const stream = + (await apiKey().application.deployWithLogs()) as unknown as AsyncIterable; + const chunks: string[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks).toEqual([`pulling with ${R}`, "done"]); + }); + + it("leave the OpenAPI document as it is", async () => { + const document = await apiKey().settings.getOpenApiDocument(); + + expect(document).toEqual({ + properties: { password: { type: "string" }, env: { type: "string" } }, + }); + }); + + it("keep revisions intact", async () => { + const matcher = buildKnownSecretMatcher(["env:Abc123-revision"]); + mocks.getKnownSecretMatcher.mockResolvedValue(matcher); + + await expect(apiKey().application.revision()).resolves.toEqual({ + revision: "env:Abc123-revision", + expectedRevision: "env:Abc123-revision", + }); + }); + + it("fail closed when the known secrets cannot be loaded", async () => { + mocks.getKnownSecretMatcher.mockRejectedValue(new Error("db down")); + + await expect(apiKey().application.one()).rejects.toThrow(); + }); +}); + +describe("procedures that return raw secrets", () => { + it.each([ + [ + "application.revealEnvironment", + () => apiKey().application.revealEnvironment(), + ], + ["postgres.revealEnvironment", () => apiKey().postgres.revealEnvironment()], + ["docker.getConfig", () => apiKey().docker.getConfig()], + ["docker.readContainerFile", () => apiKey().docker.readContainerFile()], + [ + "dockerVolume.readVolumeFile", + () => apiKey().dockerVolume.readVolumeFile(), + ], + ["settings.readTraefikEnv", () => apiKey().settings.readTraefikEnv()], + ["user.revealApiKey", () => apiKey().user.revealApiKey()], + ])("%s is refused before it runs", async (_path, call) => { + await expect(call()).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(handled).not.toHaveBeenCalled(); + }); + + it("stay available to browser sessions", async () => { + await expect(browser().application.revealEnvironment()).resolves.toEqual({ + env: SENTINEL, + }); + }); + + it("cover every reveal procedure of the app router", () => { + const reveals = Object.keys(PROCEDURE_ACCESS).filter((path) => + /reveal/i.test(path), + ); + + expect(reveals.length).toBeGreaterThan(0); + for (const path of reveals) { + expect(isProcedureDeniedForApiKey(path), path).toBe(true); + } + }); +}); + +describe("texts an API key may edit", () => { + const TOKEN = /__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/g; + + it("shows a compose file with every literal value behind a token", async () => { + const { composeFile } = await apiKey().compose.one(); + const converted = await apiKey().compose.getConvertedCompose(); + + for (const text of [composeFile, converted]) { + expect(text).not.toContain(SENTINEL); + expect(text).not.toContain("plain-neutral-value"); + expect(text).toContain("image: api:1"); + expect(text.match(TOKEN)).toHaveLength(2); + } + }); + + it("puts the values back when the edited compose file is saved", async () => { + const { composeFile } = await apiKey().compose.one(); + + await apiKey().compose.update({ + composeFile: composeFile.replace("image: api:1", "image: api:2"), + }); + + expect(saved).toHaveBeenCalledTimes(1); + const restored = saved.mock.calls[0]?.[0] as string; + expect(restored).toContain("image: api:2"); + expect(restored).toContain(SENTINEL); + expect(restored).toContain("plain-neutral-value"); + expect(restored.match(TOKEN)).toBeNull(); + }); + + it("does the same for a mounted file", async () => { + const { content } = await apiKey().mounts.one(); + expect(content).not.toContain(SENTINEL); + + await apiKey().mounts.update({ + content: content.replace("port=8080", "port=9090"), + }); + + expect(saved).toHaveBeenCalledWith( + storedMount.replace("port=8080", "port=9090"), + ); + }); + + it("rejects a compose file whose hidden value was moved into view", async () => { + const { composeFile } = await apiKey().compose.one(); + // The second value is unknown to the panel: only its place hides it. + const token = composeFile.match(TOKEN)?.[1] as string; + + await expect( + apiKey().compose.update({ + composeFile: composeFile.replace("image: api:1", `image: ${token}`), + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(saved).not.toHaveBeenCalled(); + }); + + it("rejects tokens outside the field that can restore them", async () => { + const { composeFile } = await apiKey().compose.one(); + const token = composeFile.match(TOKEN)?.[0] as string; + + await expect( + apiKey().compose.update({ + composeFile: "services: {}", + description: token, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: `run ${token}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(saved).not.toHaveBeenCalled(); + }); + + it("rejects placeholders that carry no token", async () => { + await expect( + apiKey().compose.update({ + composeFile: `services:\n a:\n image: ${R}\n`, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().mounts.update({ content: "password=[REDACTED]" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("leaves what a browser session sends untouched", async () => { + const text = `services:\n a:\n image: ${R}\n`; + + await browser().compose.update({ composeFile: text }); + + expect(saved).toHaveBeenCalledWith(text); + }); +}); + +describe("known secrets cache", () => { + it("is dropped after every mutation, from an API key or a browser", async () => { + await apiKey().application.update({ command: "run" }); + await browser().application.update({ command: "run" }); + + expect(mocks.resetKnownSecretMatcherCache).toHaveBeenCalledTimes(2); + }); + + it("is kept after a query", async () => { + await apiKey().application.one(); + await browser().application.one(); + + expect(mocks.resetKnownSecretMatcherCache).not.toHaveBeenCalled(); + }); +}); + +describe("API key writes", () => { + it("reject text that was scrubbed on the way out", async () => { + await expect( + apiKey().application.update({ command: `run --token=${R}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: "run --token=[REDACTED]" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: R }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: "run", env: `A=1\nB=${R}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(handled).not.toHaveBeenCalled(); + }); + + it("accept a whole secret field sent back as the bare placeholder", async () => { + await expect( + apiKey().application.update({ command: "run", env: R }), + ).resolves.toBe(true); + expect(handled).toHaveBeenCalledWith({ command: "run", env: R }); + }); + + it("do not restrict browser sessions", async () => { + await expect( + browser().application.update({ command: `run --token=${R}` }), + ).resolves.toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/security/compose-secrets.test.ts b/apps/dokploy/__test__/security/compose-secrets.test.ts new file mode 100644 index 00000000000..e6dd26ea76a --- /dev/null +++ b/apps/dokploy/__test__/security/compose-secrets.test.ts @@ -0,0 +1,450 @@ +import { + hideComposeSecrets, + restoreComposeSecrets, + restoreTextSecrets, + SecretRestoreError, +} from "@dokploy/server/utils/security/compose-secrets"; +import { REDACTED_SECRET_VALUE as BARE } from "@dokploy/server/utils/security/redaction"; +import { + buildKnownSecretMatcher, + EMPTY_KNOWN_SECRET_MATCHER, + findSecretTokens, + scrubSecretText, + secretTokenFor, +} from "@dokploy/server/utils/security/secret-scrub"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const NONE = EMPTY_KNOWN_SECRET_MATCHER; +const hide = (text: string, matcher = NONE) => + hideComposeSecrets(text, matcher); +const restore = (submitted: string, stored: string, matcher = NONE) => + restoreComposeSecrets(submitted, stored, matcher); + +const SECRETS = [ + "plain-neutral-value", + "quoted neutral: value", + "4242", + "list-neutral-value", + "quoted list value", + "build-arg-value", + "inline secret body", + "first line\nsecond line\n", + "shared-anchor-value", + "cli-flag-value", +]; + +const COMPOSE = `# deploy notes, password: comment-secret-1 +x-defaults: &defaults + SHARED_SETTING: shared-anchor-value +services: + api: + image: registry.example.test/team/api:1.4.2 + container_name: api + ports: + - "8080:80" + environment: + <<: *defaults + FAVOURITE_COLOUR: plain-neutral-value + GREETING: "quoted neutral: value" + PIN: 4242 + FROM_ENV: \${FROM_ENV} + FROM_PROJECT: \${{project.SHARED}} + EMPTY_ONE: + BANNER: | + first line + second line + command: server --listen 0.0.0.0 --api-token cli-flag-value # keep + labels: + - "traefik.http.routers.api.rule=Host(\`api.example.test\`)" + worker: + build: + context: . + args: + VARIANT: build-arg-value + environment: + - MODE=list-neutral-value + - "TITLE=quoted list value" + - PASSED_THROUGH + - REFERENCED=\${REFERENCED} +configs: + motd: + content: inline secret body +`; + +describe("hideComposeSecrets", () => { + const hidden = hide(COMPOSE); + + it("hides every literal value of the variable blocks, whatever the name", () => { + for (const secret of SECRETS) { + expect(hidden).not.toContain(secret.trim().split("\n")[0]); + } + expect(hidden).not.toContain("comment-secret-1"); + }); + + it("keeps names, references, empty values and the rest of the file", () => { + for (const kept of [ + "image: registry.example.test/team/api:1.4.2", + "container_name: api", + '- "8080:80"', + "<<: *defaults", + "FROM_ENV: ${FROM_ENV}", + "FROM_PROJECT: ${{project.SHARED}}", + "EMPTY_ONE:", + "- PASSED_THROUGH", + "- REFERENCED=${REFERENCED}", + "traefik.http.routers.api.rule=Host(`api.example.test`)", + "context: .", + "# keep", + ]) { + expect(hidden).toContain(kept); + } + for (const name of [ + "SHARED_SETTING", + "FAVOURITE_COLOUR", + "GREETING", + "PIN", + "BANNER", + "VARIANT", + "MODE=", + "TITLE=", + ]) { + expect(hidden).toContain(name); + } + }); + + it("stays valid YAML with the same structure", () => { + const before = parse(COMPOSE, { merge: true }); + const after = parse(hidden, { merge: true }); + + expect(Object.keys(after.services)).toEqual(Object.keys(before.services)); + expect(Object.keys(after.services.api.environment).sort()).toEqual( + Object.keys(before.services.api.environment).sort(), + ); + expect(after.services.api.image).toBe(before.services.api.image); + expect(after.services.worker.environment).toHaveLength(4); + }); + + it("gives the same value the same token and different values different ones", () => { + const text = + "services:\n a:\n environment:\n ONE: same-value-1\n TWO: same-value-1\n THREE: other-value-2\n"; + const tokens = findSecretTokens(hide(text)); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toBe(tokens[1]); + expect(tokens[0]).not.toBe(tokens[2]); + expect(tokens[0]).toBe(secretTokenFor("same-value-1")); + expect(tokens[0]).toMatch(/^__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__$/); + }); + + it("is stable when applied again", () => { + expect(hide(hidden)).toBe(hidden); + }); + + it("hides values the panel knows wherever they stand", () => { + const matcher = buildKnownSecretMatcher(["Known-Value-77"]); + const text = + "services:\n a:\n image: app\n labels:\n note: uses Known-Value-77 here\n healthcheck:\n test: curl -H 'X-Probe: Known-Value-77' localhost\n"; + + expect(hide(text, matcher)).not.toContain("Known-Value-77"); + }); + + it("hides generated-looking values in labels and commands, keeps names and routing", () => { + const text = [ + "services:", + " api:", + " image: registry.example.test/team/api:1.0.0", + " container_name: backend-prod-eu-west-01", + " hostname: api-internal-node-02", + ' command: ["server", "--mode", "fast", "Zx9PositionalValue42"]', + " labels:", + " note: Qw7-label-Neutral-0099", + " team: platform-services-eu", + " web:", + " image: web:2", + " entrypoint: run Ab12Cd34Ef56Gh78 --verbose", + " labels:", + ' - "traefik.http.routers.web-a1b2c3d4e5f6a7b8.rule=Host(`web.example.test`)"', + ' - "owner=Qw7-list-Neutral-0077"', + "", + ].join("\n"); + const hidden = hide(text); + + for (const secret of [ + "Zx9PositionalValue42", + "Qw7-label-Neutral-0099", + "Ab12Cd34Ef56Gh78", + "Qw7-list-Neutral-0077", + ]) { + expect(hidden).not.toContain(secret); + } + for (const kept of [ + "container_name: backend-prod-eu-west-01", + "hostname: api-internal-node-02", + "team: platform-services-eu", + "traefik.http.routers.web-a1b2c3d4e5f6a7b8.rule=Host(`web.example.test`)", + "owner=", + "note: ", + '"--mode"', + "--verbose", + ]) { + expect(hidden).toContain(kept); + } + expect(parse(restore(hidden, text))).toEqual(parse(text)); + }); + + it("shows nothing of a file that does not parse", () => { + expect(hide("services:\n a:\n environment:\n X: [unclosed\n")).toBe( + BARE, + ); + }); + + it("leaves an empty file alone", () => { + expect(hide("")).toBe(""); + expect(hide(" \n")).toBe(" \n"); + }); +}); + +describe("restoreComposeSecrets", () => { + const hidden = hide(COMPOSE); + + it("puts every value back when the file comes back untouched", () => { + const restored = restore(hidden, COMPOSE); + + expect(findSecretTokens(restored)).toEqual([]); + expect(parse(restored, { merge: true })).toEqual( + parse(COMPOSE, { merge: true }), + ); + expect(restored).toContain("comment-secret-1"); + }); + + it("returns a file without tokens as it is", () => { + const fresh = + "services:\n a:\n image: app:2\n environment:\n NEW: brand-new\n"; + + expect(restore(fresh, COMPOSE)).toBe(fresh); + }); + + it("keeps the hidden values through ordinary edits", () => { + const edited = hidden + .replace("api:1.4.2", "api:1.5.0") + .replace('- "8080:80"', '- "9090:80"') + .replace( + " EMPTY_ONE:\n", + " EMPTY_ONE:\n ADDED: fresh-literal\n", + ) + .replace(" - PASSED_THROUGH\n", ""); + const restored = parse(restore(edited, COMPOSE), { merge: true }); + const original = parse(COMPOSE, { merge: true }); + + expect(restored.services.api.image).toBe( + "registry.example.test/team/api:1.5.0", + ); + expect(restored.services.api.ports).toEqual(["9090:80"]); + expect(restored.services.api.environment).toEqual({ + ...original.services.api.environment, + ADDED: "fresh-literal", + }); + expect(restored.services.worker.environment).toEqual([ + "MODE=list-neutral-value", + "TITLE=quoted list value", + "REFERENCED=${REFERENCED}", + ]); + expect(restored.configs.motd.content).toBe("inline secret body"); + }); + + it("lets a value be removed, or moved into another variable", () => { + const colour = secretTokenFor("plain-neutral-value"); + const edited = hidden + .replace(` FAVOURITE_COLOUR: ${colour}\n`, "") + .replace(" - PASSED_THROUGH\n", ` - COLOUR=${colour}\n`); + const restored = parse(restore(edited, COMPOSE), { merge: true }); + + expect(restored.services.api.environment.FAVOURITE_COLOUR).toBeUndefined(); + expect(restored.services.worker.environment).toContain( + "COLOUR=plain-neutral-value", + ); + }); + + it("restores values that need quoting", () => { + const stored = `services:\n a:\n environment:\n TRICKY: 'it''s "quoted" # not a comment: yes'\n MULTI: "line one\\nline two"\n`; + const restored = parse(restore(hide(stored), stored)); + + expect(restored).toEqual(parse(stored)); + }); + + it.each([ + [ + "a placeholder without a token", + (text: string) => + text.replace(/__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, BARE), + ], + [ + "an MCP placeholder", + (text: string) => + text.replace(/__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, "[REDACTED]"), + ], + [ + "a token that was never issued", + (text: string) => + text.replace( + /__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + `${BARE}AAAAAAAAAAAA__`, + ), + ], + ["broken YAML", (text: string) => `${text}\n bad: [unclosed\n`], + ])("rejects %s", (_name, change) => { + expect(() => restore(change(hidden), COMPOSE)).toThrow(SecretRestoreError); + }); + + it.each([ + [ + "the image", + " image: registry.example.test/team/api:1.4.2", + " image: TOKEN", + ], + [ + "the container name", + " container_name: api", + " container_name: TOKEN", + ], + [ + "a label", + "traefik.http.routers.api.rule=Host(`api.example.test`)", + "note=TOKEN", + ], + ["a key", " container_name: api", " TOKEN: api"], + ])("refuses to move a hidden value into %s", (_name, from, to) => { + const token = secretTokenFor("plain-neutral-value"); + const moved = hidden.replace(from, to.replace("TOKEN", token)); + + expect(moved).not.toBe(hidden); + expect(() => restore(moved, COMPOSE)).toThrow(/would become readable/); + }); + + it("never reveals a value in its error messages", () => { + const moved = hidden.replace( + " container_name: api", + ` container_name: ${secretTokenFor("plain-neutral-value")}`, + ); + let message = ""; + try { + restore(moved, COMPOSE); + } catch (error) { + message = (error as Error).message; + } + + expect(message).not.toBe(""); + for (const secret of SECRETS) { + expect(message).not.toContain(secret); + } + }); + + it("survives random values in every style", () => { + const alphabet = "abcXYZ019 :#'\"\\{}[],&*!|>%@`=-_/\n\t$ёж"; + let seed = 20261012; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const randomValue = () => + Array.from( + { length: 1 + Math.floor(random() * 24) }, + () => alphabet[Math.floor(random() * alphabet.length)], + ).join(""); + + for (let round = 0; round < 300; round++) { + const values = Array.from({ length: 4 }, randomValue); + const stored = [ + "services:", + " app:", + " image: app:1", + " environment:", + ...values.map( + (value, index) => ` VAR_${index}: ${JSON.stringify(value)}`, + ), + " build:", + " args:", + ...values.map( + (value, index) => + ` - ${JSON.stringify(`ARG_${index}=${value}`)}`, + ), + "", + ].join("\n"); + const shown = hide(stored); + const restored = restore(shown, stored); + + expect(parse(restored), `round ${round}`).toEqual(parse(stored)); + for (const value of values) { + const literal = value.trim(); + if (literal.length >= 6 && !/^\$/.test(literal)) { + expect(shown.includes(JSON.stringify(value)), `round ${round}`).toBe( + false, + ); + } + } + } + }); +}); + +describe("restoreTextSecrets", () => { + const matcher = buildKnownSecretMatcher(["Known-Value-77"]); + const stored = [ + "server {", + ' proxy_set_header Authorization "Bearer upstream.token.value";', + " set $upstream_password hunter2-hunter2;", + " # db: postgres://app:Pa55-word@db:5432/app", + " set $marker Known-Value-77;", + " listen 8080;", + "}", + "", + ].join("\n"); + const shown = scrubSecretText(stored, matcher, { + replacement: secretTokenFor, + file: true, + }); + + it("hides the values behind tokens", () => { + for (const secret of [ + "upstream.token.value", + "Pa55-word", + "Known-Value-77", + ]) { + expect(shown).not.toContain(secret); + } + expect(shown).toContain("listen 8080;"); + expect(findSecretTokens(shown).length).toBeGreaterThanOrEqual(3); + }); + + it("puts them back exactly, also after an edit elsewhere", () => { + expect(restoreTextSecrets(shown, stored, matcher)).toBe(stored); + expect( + restoreTextSecrets( + shown.replace("listen 8080;", "listen 9090;"), + stored, + matcher, + ), + ).toBe(stored.replace("listen 8080;", "listen 9090;")); + }); + + it("refuses a value moved to where it would be readable", () => { + const token = secretTokenFor("Pa55-word"); + const moved = shown.replace("listen 8080;", `server_name ${token};`); + + expect(() => restoreTextSecrets(moved, stored, NONE)).toThrow( + SecretRestoreError, + ); + }); + + it("refuses placeholders that cannot be resolved", () => { + expect(() => restoreTextSecrets(`x ${BARE}`, stored, matcher)).toThrow( + SecretRestoreError, + ); + expect(() => restoreTextSecrets("x [REDACTED]", stored, matcher)).toThrow( + SecretRestoreError, + ); + expect(() => + restoreTextSecrets(`x ${BARE}AAAAAAAAAAAA__`, stored, matcher), + ).toThrow(SecretRestoreError); + }); +}); diff --git a/apps/dokploy/__test__/security/known-secrets.test.ts b/apps/dokploy/__test__/security/known-secrets.test.ts new file mode 100644 index 00000000000..d086b659bd0 --- /dev/null +++ b/apps/dokploy/__test__/security/known-secrets.test.ts @@ -0,0 +1,147 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: new Map[]>(), + selects: 0, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + select: () => ({ + from: async (table: unknown) => { + state.selects += 1; + return state.rows.get(table) ?? []; + }, + }), + }, +})); + +const schema = await import("@dokploy/server/db/schema"); +const { getKnownSecretMatcher, resetKnownSecretMatcherCache } = await import( + "@dokploy/server/services/known-secrets" +); +const { scrubSecretText } = await import( + "@dokploy/server/utils/security/secret-scrub" +); + +const TABLE_COUNT = 11; + +beforeEach(() => { + state.rows.clear(); + state.selects = 0; + resetKnownSecretMatcherCache(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getKnownSecretMatcher", () => { + it("collects secrets from every place the panel stores them", async () => { + state.rows.set(schema.applications, [ + { + env: "API_TOKEN=App-Env-Value-1", + previewEnv: "API_TOKEN=App-Preview-Value-1", + buildArgs: "NPM_TOKEN=App-BuildArg-Value-1", + buildSecrets: "SIGNING_KEY=App-BuildSecret-Value-1", + previewBuildArgs: "NPM_TOKEN=App-PreviewArg-Value-1", + previewBuildSecrets: "SIGNING_KEY=App-PreviewSecret-Value-1", + password: "App-Registry-Pass-1", + }, + ]); + state.rows.set(schema.compose, [ + { + env: "JWT_SECRET=Compose-Value-1", + composeFile: + "services:\n api:\n environment:\n API_TOKEN: Compose-Inline-1\n - bad", + }, + { + env: null, + composeFile: + "services:\n api:\n environment:\n API_TOKEN: Compose-Inline-2\n COLOUR: teal\n build:\n args:\n - NPM_TOKEN=Compose-Inline-3\n", + }, + ]); + state.rows.set(schema.postgres, [ + { env: null, password: "Postgres-Pass-1" }, + ]); + state.rows.set(schema.mysql, [ + { env: "", password: "Mysql-Pass-1", rootPassword: "Mysql-Root-1" }, + ]); + state.rows.set(schema.mariadb, [ + { env: "", password: "Maria-Pass-1", rootPassword: "Maria-Root-1" }, + ]); + state.rows.set(schema.mongo, [{ env: "", password: "Mongo-Pass-1" }]); + state.rows.set(schema.redis, [{ env: "", password: "Redis-Pass-1" }]); + state.rows.set(schema.libsql, [{ env: "", password: "Libsql-Pass-1" }]); + state.rows.set(schema.projects, [{ env: "SHARED_KEY=Project-Value-1" }]); + state.rows.set(schema.environments, [ + { env: "SHARED_KEY=Environment-Value-1" }, + ]); + state.rows.set(schema.registry, [{ password: "Registry-Pass-1" }]); + + const matcher = await getKnownSecretMatcher(); + const secrets = [ + "App-Env-Value-1", + "App-Preview-Value-1", + "App-BuildArg-Value-1", + "App-BuildSecret-Value-1", + "App-PreviewArg-Value-1", + "App-PreviewSecret-Value-1", + "App-Registry-Pass-1", + "Compose-Value-1", + "Compose-Inline-2", + "Compose-Inline-3", + "Postgres-Pass-1", + "Mysql-Pass-1", + "Mysql-Root-1", + "Maria-Pass-1", + "Maria-Root-1", + "Mongo-Pass-1", + "Redis-Pass-1", + "Libsql-Pass-1", + "Project-Value-1", + "Environment-Value-1", + "Registry-Pass-1", + ]; + + expect(matcher.size).toBe(secrets.length); + expect(scrubSecretText(secrets.join(" | "), matcher)).toBe( + secrets.map(() => R).join(" | "), + ); + }); + + it("reuses the result until the cache expires or is reset", async () => { + vi.useFakeTimers(); + + await getKnownSecretMatcher(); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT); + + vi.advanceTimersByTime(31_000); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT * 2); + + resetKnownSecretMatcherCache(); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT * 3); + }); + + it("shares one load between concurrent callers", async () => { + await Promise.all([getKnownSecretMatcher(), getKnownSecretMatcher()]); + + expect(state.selects).toBe(TABLE_COUNT); + }); + + it("does not cache a failed load", async () => { + const failure = new Error("db down"); + const original = state.rows.get; + state.rows.get = () => { + throw failure; + }; + await expect(getKnownSecretMatcher()).rejects.toThrow("db down"); + + state.rows.get = original; + await expect(getKnownSecretMatcher()).resolves.toMatchObject({ size: 0 }); + }); +}); diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts new file mode 100644 index 00000000000..a053714a10a --- /dev/null +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -0,0 +1,502 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { + isSensitiveOutputKey, + scrubSecretOutput, +} from "@dokploy/server/utils/security/secret-output"; +import { + buildKnownSecretMatcher, + collectEnvSecretValues, + collectSecretValue, + scrubSecretText, +} from "@dokploy/server/utils/security/secret-scrub"; +import { describe, expect, it } from "vitest"; + +const SENTINEL = "Zx9-sentinel-Value42"; + +describe("isSensitiveOutputKey", () => { + it.each([ + "env", + "previewEnv", + "Env", + "buildArgs", + "buildSecrets", + "previewBuildSecrets", + "password", + "databaseRootPassword", + "refreshToken", + "metricsToken", + "clientSecret", + "privateKey", + "apiKey", + "accessKey", + "secretAccessKey", + "webhookUrl", + "headers", + "licenseKey", + "authorization", + ])("treats %s as secret-bearing", (key) => { + expect(isSensitiveOutputKey(key)).toBe(true); + }); + + it.each([ + "environment", + "environmentId", + "secretName", + "apiKeyId", + "tokenType", + "apiKeys", + "passwordUpdatedAt", + "publicKey", + "createEnvFile", + "composeFile", + "content", + "command", + ])("leaves %s to the text scrubber", (key) => { + expect(isSensitiveOutputKey(key)).toBe(false); + }); +}); + +describe("scrubSecretOutput", () => { + it("blanks secret fields at any depth and keeps the rest", () => { + const scrubbed = scrubSecretOutput({ + applicationId: "app-1", + name: "api", + env: `TOKEN=${SENTINEL}\nPORT=3000`, + environment: { + name: "production", + env: `SHARED=${SENTINEL}`, + project: { name: "shop", env: `ROOT=${SENTINEL}` }, + }, + registry: { registryUrl: "registry.example.test", password: SENTINEL }, + }); + + expect(scrubbed).toEqual({ + applicationId: "app-1", + name: "api", + env: R, + environment: { + name: "production", + env: R, + project: { name: "shop", env: R }, + }, + registry: { registryUrl: "registry.example.test", password: R }, + }); + }); + + it("keeps flags, numbers, nulls and empty strings under secret-looking keys", () => { + const value = { + variables: [{ name: "API_TOKEN", secret: true, empty: false }], + password: null, + env: "", + inputTokens: 12, + }; + + expect(scrubSecretOutput(value)).toEqual(value); + }); + + it("keeps the names of a docker Env list and of a header map", () => { + const scrubbed = scrubSecretOutput({ + Config: { + Env: [`API_TOKEN=${SENTINEL}`, "PATH=/usr/bin", "not an assignment"], + }, + headers: { Authorization: `Bearer ${SENTINEL}`, "X-Trace": "on" }, + }); + + expect(scrubbed).toEqual({ + Config: { Env: [`API_TOKEN=${R}`, `PATH=${R}`, R] }, + headers: { Authorization: R, "X-Trace": R }, + }); + }); + + it("never rewrites image references", () => { + const value = { + dockerImage: "auth-service:release-2", + Image: "registry.example.test/team/token-api:main", + }; + + expect(scrubSecretOutput(value)).toEqual(value); + }); + + it("never rewrites identifiers and names", () => { + const matcher = buildKnownSecretMatcher(["svc-auth-Prod1"]); + const value = { + appName: "svc-auth-Prod1", + name: "svc-auth-Prod1", + applicationId: "svc-auth-Prod1", + containerName: "svc-auth-Prod1.1.abc", + description: "runs svc-auth-Prod1", + }; + + expect(scrubSecretOutput(value, matcher)).toEqual({ + ...value, + description: `runs ${R}`, + }); + }); + + it("leaves dates and other non-plain values untouched", () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + + expect(scrubSecretOutput({ createdAt, count: 3, ok: true })).toEqual({ + createdAt, + count: 3, + ok: true, + }); + }); + + it("hides every literal of a compose file behind a token and scrubs commands", () => { + const scrubbed = scrubSecretOutput({ + composeFile: [ + "services:", + " api:", + " image: api:1.2.3", + " environment:", + " DATABASE_PASSWORD: hunter2-hunter2", + " SESSION_SECRET: ${SESSION_SECRET}", + " FAVOURITE_COLOUR: teal", + ].join("\n"), + command: "server --api-token=abc123 --port 8080", + }) as { composeFile: string; command: string }; + + expect(scrubbed.composeFile).toMatch( + /DATABASE_PASSWORD: __DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + ); + expect(scrubbed.composeFile).toMatch( + /FAVOURITE_COLOUR: __DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + ); + expect(scrubbed.composeFile).toContain("SESSION_SECRET: ${SESSION_SECRET}"); + expect(scrubbed.composeFile).toContain("image: api:1.2.3"); + expect(scrubbed.composeFile).not.toMatch(/hunter2|teal/); + expect(scrubbed.command).toBe(`server --api-token=${R} --port 8080`); + }); + + it("marks the hidden parts of a mounted file with tokens", () => { + const scrubbed = scrubSecretOutput({ + mounts: [ + { mountId: "m-1", content: "user=app\npassword=hunter2-hunter2\n" }, + ], + }) as { mounts: { content: string }[] }; + + expect(scrubbed.mounts[0]?.content).toMatch( + /^user=app\npassword=__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__\n$/, + ); + }); +}); + +describe("scrubSecretText", () => { + it("removes known secret values wherever they appear", () => { + const matcher = buildKnownSecretMatcher([SENTINEL]); + + expect( + scrubSecretText( + `connecting with ${SENTINEL}\nretry ${SENTINEL}!`, + matcher, + ), + ).toBe(`connecting with ${R}\nretry ${R}!`); + }); + + it("replaces a secret that contains another one as a whole", () => { + const matcher = buildKnownSecretMatcher(["abcd1234", "abcd1234-wxyz5678"]); + + expect(scrubSecretText("key abcd1234-wxyz5678 end", matcher)).toBe( + `key ${R} end`, + ); + }); + + it.each([ + ["DB_PASS=s3cr3tValue", `DB_PASS=${R}`], + ["export JWT_SIGNING_KEY='abc def'", `export JWT_SIGNING_KEY=${R}`], + ["SESSION_SALT=qwerty", `SESSION_SALT=${R}`], + ["SENTRY_DSN=https://key@sentry.example.test/1", `SENTRY_DSN=${R}`], + ['{"client_secret": "abc"}', `{"client_secret": ${R}}`], + ["postgres://app:pa55word@db:5432/app", `postgres://${R}@db:5432/app`], + ["Authorization: Bearer abc.def.ghi", `Authorization: ${R}`], + [`token ghp_${"a".repeat(36)} used`, `token ${R} used`], + [`github_pat_${"A1".repeat(20)}`, R], + [`glpat-${"b".repeat(20)}`, R], + ["xoxb-1234567890-abcdefghij", R], + [`sk-${"c".repeat(24)}`, R], + ["AKIAIOSFODNN7EXAMPLE", R], + [`eyJ${"a".repeat(12)}.eyJ${"b".repeat(12)}.${"c".repeat(12)}`, R], + ])("scrubs %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it("removes a private key block, even a truncated one", () => { + const block = + "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nIBAAK\n-----END RSA PRIVATE KEY-----"; + + expect(scrubSecretText(`before\n${block}\nafter`)).toBe( + `before\n${R}\nafter`, + ); + expect( + scrubSecretText("x\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3Blbn"), + ).toBe(`x\n${R}`); + }); + + it.each([ + "DB_PASSWORD=${DB_PASSWORD}", + "API_TOKEN=$API_TOKEN", + "JWT_SECRET: ${{project.JWT_SECRET}}", + 'SESSION_SECRET: "${SESSION_SECRET:?required}"', + "postgres://${DB_USER}:${DB_PASSWORD}@db:5432/app", + ])("keeps the variable reference in %s", (input) => { + expect(scrubSecretText(input)).toBe(input); + }); + + it("scrubs the inline default of a variable reference", () => { + expect(scrubSecretText("DB_PASSWORD=${DB_PASSWORD:-hunter2}")).toBe( + `DB_PASSWORD=${R}`, + ); + }); + + it.each([ + "redis://session-store:6379/0", + "https://auth.example.test:8443/login", + "SMTP_HOST: mail.auth.example:587", + "upstream token-service:8080 is healthy", + "listening on port 3000, workers: 4", + "image: auth-service:1.2.3-alpine", + "image: session-store:latest", + 'ports:\n - "auth-proxy:8443"', + ])( + "does not read a host, port or image tag as a key and value in %s", + (input) => { + expect(scrubSecretText(input)).toBe(input); + }, + ); + + it("leaves the structure of YAML and JSON with secret-looking names", () => { + const yaml = [ + "http:", + " routers:", + " auth-service-router:", + " rule: Host(`app.example.test`)", + " service: auth-service", + "services:", + " session-store:", + " image: redis:7", + ].join("\n"); + const json = + '{\n "auth": {\n "user": "app"\n },\n "tokens": ["a"]\n}'; + + expect(scrubSecretText(yaml)).toBe(yaml); + expect(scrubSecretText(json)).toBe(json); + }); + + it.each([ + ['{"my secret key": "abc"}', `{"my secret key": ${R}}`], + ["password:abc123", `password:${R}`], + [" - API_KEY: abc", ` - API_KEY: ${R}`], + [ + "?next=/home&access-token-v2=abc&x=1", + `?next=/home&access-token-v2=${R}&x=1`, + ], + ])("scrubs the value in %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it("scrubs a very long line piece by piece without losing text", () => { + const filler = "lorem ipsum dolor sit amet ".repeat(400); + const line = `${filler}DB_PASS=first-Secret1 ${filler}{"api_key":"second-Secret2"},${"x".repeat(9000)}&token=third-Secret3`; + const scrubbed = scrubSecretText( + `head\n${line}\ntail DB_PASS=fourth-Secret4`, + ); + + expect(scrubbed).not.toMatch(/(?:first|second|third|fourth)-Secret/); + expect(scrubbed.startsWith(`head\n${filler}DB_PASS=${R} ${filler}`)).toBe( + true, + ); + expect(scrubbed.endsWith(`\ntail DB_PASS=${R}`)).toBe(true); + expect(scrubSecretText(`${filler}${filler}`)).toBe(`${filler}${filler}`); + }); + + it.each([ + ["dashes", "ab-".repeat(200_000)], + ["dots", "ab.cd".repeat(120_000)], + ["assignments", "A=".repeat(300_000)], + ["keywords", "pass-".repeat(120_000)], + ["urls", "a://b".repeat(120_000)], + ["jwt heads", "eyJ-".repeat(150_000)], + ["token prefixes", "sk-".repeat(200_000)], + ["flags", " --a".repeat(150_000)], + ["clients", "mysql ".repeat(100_000)], + ])("stays fast on a hostile line of %s", (_name, input) => { + const startedAt = performance.now(); + + scrubSecretText(input); + + expect(performance.now() - startedAt).toBeLessThan(3_000); + }); + + it.each([ + ["password = correct horse battery", `password = ${R}`], + [" db.password = s3cr3tValue", ` db.password = ${R}`], + ['password = "quoted value"', `password = ${R}`], + ["password: correct horse battery", `password: ${R}`], + ["password: 12345", `password: ${R}`], + [" - api_key: abc def", ` - api_key: ${R}`], + ["retry with token = abc123 now", `retry with token = ${R} now`], + ["'password' => 'abc'", `'password' => ${R}`], + ["abc def", `${R}`], + ['', ``], + ])("scrubs the value in %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it.each([ + "if (password == null) return", + "password =~ /^.{12,}$/", + "version=1.2.3 build=42", + ])("leaves %s alone", (input) => { + expect(scrubSecretText(input)).toBe(input); + }); + + it("reads `name value` settings only in a file", () => { + const file = + "requirepass hunter2-hunter2\nauth_basic_user_file /etc/users;\nlisten 8080;\n"; + + expect(scrubSecretText(file, undefined, { file: true })).toBe( + `requirepass ${R}\nauth_basic_user_file ${R};\nlisten 8080;\n`, + ); + expect(scrubSecretText("Passing health check on port 8080")).toBe( + "Passing health check on port 8080", + ); + }); + + it("hides a generated-looking value under any name in a file, and nothing else", () => { + const file = [ + "greeting = Zx9-neutral-Value42x", + "checksum: 9f86d081884c7d659a2feaa0c55ad015", + 'label = "AbCdEf0123456789xyz"', + "job_name: node-exporter-prod-01", + "server_name app.example.test;", + "upstream = http://backend-service-01:8080/", + "root = /var/www/releases/2026-10-12", + "workers = 1024", + "", + ].join("\n"); + + expect(scrubSecretText(file, undefined, { file: true })).toBe( + [ + `greeting = ${R}`, + `checksum: ${R}`, + `label = "${R}"`, + "job_name: node-exporter-prod-01", + "server_name app.example.test;", + "upstream = http://backend-service-01:8080/", + "root = /var/www/releases/2026-10-12", + "workers = 1024", + "", + ].join("\n"), + ); + expect(scrubSecretText("request id=Zx9-neutral-Value42x done")).toBe( + "request id=Zx9-neutral-Value42x done", + ); + }); + + it("is idempotent", () => { + const once = scrubSecretText( + "DB_PASS=abc postgres://u:p4ssw0rd@h/db Authorization: Bearer x.y.z", + ); + + expect(scrubSecretText(once)).toBe(once); + }); + + it("handles a large log quickly", () => { + const matcher = buildKnownSecretMatcher( + Array.from({ length: 300 }, (_, index) => `Secret-${index}-Value9`), + ); + const line = `2026-01-01T00:00:00Z info request id=42 path=/health status=200 ${SENTINEL}\n`; + const log = line.repeat(12_000); + const startedAt = performance.now(); + + const scrubbed = scrubSecretText(log, buildKnownSecretMatcher([SENTINEL])); + scrubSecretText(log, matcher); + + expect(performance.now() - startedAt).toBeLessThan(5_000); + expect(scrubbed).not.toContain(SENTINEL); + }); +}); + +describe("known secret values", () => { + it("collects secret-looking variables, opaque values and URL passwords", () => { + const opaque = "a1".repeat(20); + const values = collectEnvSecretValues( + [ + `API_TOKEN=${SENTINEL}`, + `BUILD_STAMP=${opaque}`, + "DATABASE_URL=postgres://app:Pa55-word@db:5432/app", + "PORT=3000", + "NODE_ENV=production", + "APP_URL=https://app.example.test", + ].join("\n"), + ); + + expect(values.sort()).toEqual([SENTINEL, opaque, "Pa55-word"].sort()); + }); + + it("skips values that would mangle ordinary output", () => { + expect(collectSecretValue("DB_PASSWORD", "postgres")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "1234567890")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "short1")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "two words here")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "${DB_PASSWORD}")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", R)).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", null)).toEqual([]); + }); + + it("also scrubs the decoded form of a URL-encoded password", () => { + expect( + collectSecretValue( + "DATABASE_URL", + "mysql://app:p%40ss-W0rd@db/app", + ).sort(), + ).toEqual(["p%40ss-W0rd", "p@ss-W0rd"].sort()); + }); + + it("also catches a known value printed in base64 or hex", () => { + const secret = "Zx9-sentinel-Value42"; + const matcher = buildKnownSecretMatcher([secret]); + const encodings = [ + Buffer.from(secret).toString("base64"), + Buffer.from(`user:${secret}`).toString("base64"), + Buffer.from(`ab${secret}\nNEXT=1`).toString("base64"), + Buffer.from(`abc${secret}tail`).toString("base64url"), + Buffer.from(secret).toString("hex"), + Buffer.from(secret).toString("hex").toUpperCase(), + ]; + + for (const encoded of encodings) { + const scrubbed = scrubSecretText(`dump ${encoded} end`, matcher); + expect(scrubbed, encoded).toContain(R); + expect(scrubbed, encoded).not.toContain(encoded.slice(6, 22)); + } + }); + + it("matches the longest of overlapping secrets among many", () => { + const secrets = Array.from( + { length: 400 }, + (_, index) => `Shared-Prefix-${index}-Tail9`, + ); + const matcher = buildKnownSecretMatcher([ + ...secrets, + "Shared-Prefix-7", + "Shared-Prefix-7-Tail9-Extended", + ]); + + expect(matcher.size).toBe(402); + expect( + scrubSecretText( + "a Shared-Prefix-7-Tail9-Extended b Shared-Prefix-399-Tail9 c Shared-Prefix-7 d", + matcher, + ), + ).toBe(`a ${R} b ${R} c ${R} d`); + }); + + it("returns an empty matcher when nothing qualifies", () => { + const matcher = buildKnownSecretMatcher(["short", "postgres"]); + + expect(matcher.size).toBe(0); + expect(scrubSecretText("postgres short", matcher)).toBe("postgres short"); + }); +}); diff --git a/apps/dokploy/__test__/security/super-session-guard.test.ts b/apps/dokploy/__test__/security/super-session-guard.test.ts index e309b20cc98..1ba696b0318 100644 --- a/apps/dokploy/__test__/security/super-session-guard.test.ts +++ b/apps/dokploy/__test__/security/super-session-guard.test.ts @@ -41,6 +41,16 @@ vi.mock("@/server/api/utils/audit", () => ({ audit: mocks.audit, })); +vi.mock("@dokploy/server/services/known-secrets", async () => { + const { EMPTY_KNOWN_SECRET_MATCHER } = await import( + "@dokploy/server/utils/security/secret-scrub" + ); + return { + getKnownSecretMatcher: async () => EMPTY_KNOWN_SECRET_MATCHER, + resetKnownSecretMatcherCache: () => {}, + }; +}); + const schema = await import("@dokploy/server/db/schema"); const { fake } = (await import("@dokploy/server/db")) as unknown as { fake: ReturnType< @@ -162,9 +172,6 @@ describe("super session guard without a super password", () => { await expect(apiKeyCaller().application.update()).resolves.toBe( "application.update", ); - await expect(apiKeyCaller().application.revealEnvironment()).resolves.toBe( - "application.revealEnvironment", - ); await expect(browserCaller().application.revealEnvironment()).resolves.toBe( "application.revealEnvironment", ); @@ -172,6 +179,17 @@ describe("super session guard without a super password", () => { browserCaller().user.update({ email: "new@example.test" }), ).resolves.toBe("user.update"); }); + + it("never reveals secrets to API keys, with or without a super password", async () => { + await expect( + apiKeyCaller().application.revealEnvironment(), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "This procedure returns secret values and is not available to API keys", + }); + expect(handled).not.toHaveBeenCalled(); + }); }); describe("super session guard with a super password", () => { diff --git a/apps/dokploy/__test__/wss/api-key.test.ts b/apps/dokploy/__test__/wss/api-key.test.ts new file mode 100644 index 00000000000..fd6afdc20f3 --- /dev/null +++ b/apps/dokploy/__test__/wss/api-key.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { rejectApiKeySession } from "../../server/wss/api-key"; + +describe("rejectApiKeySession", () => { + it("closes a socket opened with an API key", () => { + const ws = { close: vi.fn() }; + + expect(rejectApiKeySession(ws, { authMethod: "api-key" })).toBe(true); + expect(ws.close).toHaveBeenCalledWith(4003, "Not available to API keys"); + }); + + it.each([{ authMethod: "session" }, {}, null, undefined])( + "lets a browser session through (%o)", + (session) => { + const ws = { close: vi.fn() }; + + expect(rejectApiKeySession(ws, session)).toBe(false); + expect(ws.close).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/dokploy/pages/api/application.env.upsert.ts b/apps/dokploy/pages/api/application.env.upsert.ts index 361913afb2d..71697e67d34 100644 --- a/apps/dokploy/pages/api/application.env.upsert.ts +++ b/apps/dokploy/pages/api/application.env.upsert.ts @@ -5,6 +5,7 @@ import { validateRequest, } from "@dokploy/server"; import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema"; +import { resetKnownSecretMatcherCache } from "@dokploy/server/services/known-secrets"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; import { checkSuperSessionAccess, @@ -114,6 +115,7 @@ export const handleApplicationEnvUpsert = async ( let redeployed = false; if (!result.dryRun && result.changed) { + resetKnownSecretMatcherCache(); const application = await findApplicationById(input.applicationId); await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 9344eac4da3..4badf8f4285 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -20,6 +20,7 @@ import { suggestVariants, } from "@dokploy/server/services/ai"; import { createComposeByTemplate } from "@dokploy/server/services/compose"; +import { getKnownSecretMatcher } from "@dokploy/server/services/known-secrets"; import { addNewService, checkPermission, @@ -36,6 +37,7 @@ import { redactAiSettingsSecrets, redactAiSettingsSecretsList, } from "@dokploy/server/utils/security/redaction"; +import { scrubSecretText } from "@dokploy/server/utils/security/secret-scrub"; import { fetchWithPublicEgress } from "@dokploy/server/utils/url/network"; import { TRPCError } from "@trpc/server"; import { generateText } from "ai"; @@ -302,6 +304,8 @@ export const aiRouter = createTRPCRouter({ const contextLabel = input.context === "build" ? "build/deployment" : "runtime/container"; + // The logs go to a third-party model, so they leave without secrets. + const logs = scrubSecretText(input.logs, await getKnownSecretMatcher()); const result = await generateText({ model, @@ -315,7 +319,7 @@ export const aiRouter = createTRPCRouter({ Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly. Logs: -${input.logs}`, +${logs}`, }); return { analysis: result.text }; diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 39d56f6ca38..ce93c44a712 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -101,6 +101,7 @@ import { import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { restoreApiKeyText } from "../utils/api-key-output-guard"; import { audit } from "../utils/audit"; import { assertDeploySourceCredentialAccess } from "../utils/deploy-source-access"; import { assertTargetEnvironmentAccess } from "../utils/placement-access"; @@ -402,7 +403,15 @@ export const composeRouter = createTRPCRouter({ }); const currentCompose = await findComposeById(input.composeId); const updateData = preserveSecretPlaceholderFields( - input, + { + ...input, + composeFile: await restoreApiKeyText( + ctx.session, + "compose", + input.composeFile, + currentCompose.composeFile, + ), + }, currentCompose, ["env", "composeFile", "customGitUrl"], ); diff --git a/apps/dokploy/server/api/routers/mount.ts b/apps/dokploy/server/api/routers/mount.ts index 9d9e0a06eb5..19f6e83d8a6 100644 --- a/apps/dokploy/server/api/routers/mount.ts +++ b/apps/dokploy/server/api/routers/mount.ts @@ -21,6 +21,7 @@ import { } from "@dokploy/server/services/permission"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { restoreApiKeyText } from "@/server/api/utils/api-key-output-guard"; import { audit } from "@/server/api/utils/audit"; import { apiCreateMount, @@ -159,7 +160,15 @@ export const mountRouter = createTRPCRouter({ resourceId: input.mountId, resourceName: input.mountPath, }); - return await updateMount(input.mountId, input); + return await updateMount(input.mountId, { + ...input, + content: await restoreApiKeyText( + ctx.session, + "file", + input.content, + mount.content, + ), + }); }), allNamedByApplicationId: protectedProcedure .input(z.object({ applicationId: z.string().min(1) })) diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index 9d0c388d92d..47f89e04b6c 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -11,6 +11,7 @@ import { db } from "@dokploy/server/db"; import type { statements } from "@dokploy/server/lib/access-control"; import { validateRequest } from "@dokploy/server/lib/auth"; +import { resetKnownSecretMatcherCache } from "@dokploy/server/services/known-secrets"; import { checkPermission } from "@dokploy/server/services/permission"; import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; import { getSuperSessionDenial } from "@dokploy/server/services/super-password"; @@ -20,7 +21,16 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import type { Session, User } from "better-auth"; import superjson from "superjson"; import { ZodError } from "zod"; -import { enforceProcedureSuperSession } from "@/server/api/utils/super-session"; +import { + assertNoRedactedTextInInput, + assertProcedureAllowedForApiKey, + scrubApiKeyError, + scrubApiKeyResult, +} from "@/server/api/utils/api-key-output-guard"; +import { + enforceProcedureSuperSession, + isApiKeySession, +} from "@/server/api/utils/super-session"; type Resource = keyof typeof statements; type ActionOf = (typeof statements)[R][number]; @@ -161,7 +171,33 @@ const superSessionGuard = t.middleware( }, ); -const baseProcedure = t.procedure.use(superSessionGuard); +// An API key is the channel scripts and AI agents use, so nothing it receives +// may contain a secret value, whatever role the key's owner has. +const apiKeyOutputGuard = t.middleware( + async ({ ctx, path, type, getRawInput, next }) => { + const viaApiKey = isApiKeySession(ctx.session); + if (viaApiKey) { + assertProcedureAllowedForApiKey(path); + if (type === "mutation") { + assertNoRedactedTextInInput(path, await getRawInput()); + } + } + const result = await next(); + // Any write may have stored a new secret, whoever made it. + if (type === "mutation") { + resetKnownSecretMatcherCache(); + } + if (!viaApiKey) { + return result; + } + if (result.ok) { + return { ...result, data: await scrubApiKeyResult(path, result.data) }; + } + return { ...result, error: await scrubApiKeyError(result.error) }; + }, +); + +const baseProcedure = t.procedure.use(superSessionGuard).use(apiKeyOutputGuard); /** * Public (unauthenticated) procedure diff --git a/apps/dokploy/server/api/utils/api-key-output-guard.ts b/apps/dokploy/server/api/utils/api-key-output-guard.ts new file mode 100644 index 00000000000..2fe60e7104e --- /dev/null +++ b/apps/dokploy/server/api/utils/api-key-output-guard.ts @@ -0,0 +1,163 @@ +import { getKnownSecretMatcher } from "@dokploy/server/services/known-secrets"; +import { + hideComposeSecrets, + restoreComposeSecrets, + restoreTextSecrets, + SecretRestoreError, +} from "@dokploy/server/utils/security/compose-secrets"; +import { + isExactSecretPlaceholder, + isSecretPlaceholderValue, +} from "@dokploy/server/utils/security/redaction"; +import { + isSensitiveOutputKey, + scrubSecretOutput, +} from "@dokploy/server/utils/security/secret-output"; +import { + EMPTY_KNOWN_SECRET_MATCHER, + scrubSecretText, +} from "@dokploy/server/utils/security/secret-scrub"; +import { TRPCError } from "@trpc/server"; +import { isApiKeySession } from "@/server/api/utils/super-session"; + +// These hand back a stored secret or raw file bytes, which no scrubbing can +// make safe (a container file may be nothing but one token), so an API key +// cannot call them at all. +const API_KEY_DENIED_PROCEDURES = new Set([ + "docker.getConfig", + "docker.readContainerFile", + "dockerVolume.readVolumeFile", + "settings.readTraefikEnv", + "user.revealApiKey", +]); + +// The OpenAPI document describes fields named `password` or `env` without +// holding a value, and blanking it would break the clients generated from it. +const API_KEY_UNSCRUBBED_PROCEDURES = new Set(["settings.getOpenApiDocument"]); + +export const isProcedureDeniedForApiKey = (path: string) => + API_KEY_DENIED_PROCEDURES.has(path) || path.endsWith(".revealEnvironment"); + +export const assertProcedureAllowedForApiKey = (path: string) => { + if (isProcedureDeniedForApiKey(path)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "This procedure returns secret values and is not available to API keys", + }); + } +}; + +// These procedures answer with a compose file as a bare string. +const COMPOSE_TEXT_PROCEDURES = new Set([ + "compose.getConvertedCompose", + "compose.isolatedDeployment", + "compose.randomizeCompose", +]); + +// The one field of each of these procedures that may carry tokens: the +// procedure puts the hidden values back from the stored text. +const RESTORED_INPUT_FIELDS: Record = { + "compose.update": "composeFile", + "mounts.update": "content", +}; + +const findRedactedText = (value: unknown, key: string | null): boolean => { + if (typeof value === "string") { + if (!isSecretPlaceholderValue(value)) { + return false; + } + return !( + key !== null && + isSensitiveOutputKey(key) && + isExactSecretPlaceholder(value) + ); + } + if (Array.isArray(value)) { + return value.some((item) => findRedactedText(item, key)); + } + if (value !== null && typeof value === "object") { + return Object.entries(value).some(([childKey, child]) => + findRedactedText(child, childKey), + ); + } + return false; +}; + +// Text that was scrubbed on the way out must not come back in: saving it would +// overwrite the real value with the placeholder. A whole secret field sent +// back as the bare placeholder still means "keep what is stored". +export const assertNoRedactedTextInInput = (path: string, input: unknown) => { + const restoredField = RESTORED_INPUT_FIELDS[path]; + const checked = + restoredField && input !== null && typeof input === "object" + ? { ...input, [restoredField]: undefined } + : input; + if (findRedactedText(checked, null)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The input contains redacted text. Hidden values can only be sent back as tokens in a compose file (compose.update) or a file mount (mounts.update); change environment variables with env.apply", + }); + } +}; + +const isAsyncIterable = (value: unknown): value is AsyncIterable => + value !== null && + typeof value === "object" && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function"; + +async function* scrubStream(source: AsyncIterable) { + for await (const chunk of source) { + yield scrubSecretOutput(chunk, await getKnownSecretMatcher()); + } +} + +export const scrubApiKeyResult = async (path: string, data: unknown) => { + if (API_KEY_UNSCRUBBED_PROCEDURES.has(path)) { + return data; + } + if (isAsyncIterable(data)) { + return scrubStream(data); + } + const matcher = await getKnownSecretMatcher(); + return typeof data === "string" && COMPOSE_TEXT_PROCEDURES.has(path) + ? hideComposeSecrets(data, matcher) + : scrubSecretOutput(data, matcher); +}; + +type RestoreSession = Parameters[0]; + +// An API key reads these texts with their secrets replaced by tokens. When it +// sends the text back, the values behind the tokens are put back from what is +// stored, so the caller can edit the rest without ever seeing them. +export const restoreApiKeyText = async ( + session: RestoreSession, + kind: "compose" | "file", + submitted: T, + stored: string | null | undefined, +): Promise => { + if (!isApiKeySession(session) || typeof submitted !== "string") { + return submitted; + } + const restore = + kind === "compose" ? restoreComposeSecrets : restoreTextSecrets; + try { + return restore(submitted, stored, await getKnownSecretMatcher()); + } catch (error) { + if (error instanceof SecretRestoreError) { + throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); + } + throw error; + } +}; + +export const scrubApiKeyError = async (error: TRPCError) => { + const matcher = await getKnownSecretMatcher().catch( + () => EMPTY_KNOWN_SECRET_MATCHER, + ); + const message = scrubSecretText(error.message, matcher); + return message === error.message + ? error + : new TRPCError({ code: error.code, message, cause: error.cause }); +}; diff --git a/apps/dokploy/server/wss/api-key.ts b/apps/dokploy/server/wss/api-key.ts new file mode 100644 index 00000000000..505bc387835 --- /dev/null +++ b/apps/dokploy/server/wss/api-key.ts @@ -0,0 +1,17 @@ +import type { WebSocket } from "ws"; + +type WssSession = { authMethod?: string | null } | null | undefined; + +// A shell or a raw log stream carries whatever the container prints, and a +// stream cannot be scrubbed of a secret split across two chunks. API keys read +// logs through the readLogs procedures, whose output is scrubbed. +export const rejectApiKeySession = ( + ws: Pick, + session: WssSession, +) => { + if (session?.authMethod !== "api-key") { + return false; + } + ws.close(4003, "Not available to API keys"); + return true; +}; diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index 38ae6a8b2b1..a662955b81a 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -4,6 +4,7 @@ import { resolveServerDestinationHost } from "@dokploy/server/utils/servers/dest import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerLogsWebSocket } from "./docker-permission"; import { verifyWebSocketOrigin } from "./origin"; @@ -53,6 +54,9 @@ export const setupDockerContainerLogsWebSocketServer = ( ws.close(); return; } + if (rejectApiKeySession(ws, session)) { + return; + } if (!containerId) { ws.close(4000, "containerId no provided"); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index e0042de8194..ec354937729 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,6 +4,7 @@ import { resolveServerDestinationHost } from "@dokploy/server/utils/servers/dest import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerTerminalWebSocket } from "./docker-permission"; import { verifyWebSocketOrigin } from "./origin"; @@ -95,6 +96,9 @@ export const setupDockerContainerTerminalWebSocketServer = ( if (await rejectTerminalWithoutSuperSession(ws, user, session)) { return; } + if (rejectApiKeySession(ws, session)) { + return; + } try { if (serverId) { const server = await findServerById(serverId); diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index 4e2a09d0190..dab12e4d6ea 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -18,6 +18,7 @@ import { } from "@dokploy/server/wss/utils"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { verifyWebSocketOrigin } from "./origin"; const getDeploymentLogPathRoot = ( @@ -222,6 +223,9 @@ export const setupDeploymentLogsWebSocketServer = ( ws.close(); return; } + if (rejectApiKeySession(ws, session)) { + return; + } if (!deploymentId) { ws.close(4000, "deploymentId required"); diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 2ef390c4579..00ce8f7ca63 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,6 +10,7 @@ import { publicIpv4, publicIpv6 } from "public-ip"; import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; +import { rejectApiKeySession } from "./api-key"; import { canAccessTerminalOverWss } from "./authorize"; import { verifyWebSocketOrigin } from "./origin"; import { canAccessServerTerminalWebSocket } from "./server-permission"; @@ -121,6 +122,9 @@ export const setupTerminalWebSocketServer = ( if (await rejectTerminalWithoutSuperSession(ws, user, session)) { return; } + if (rejectApiKeySession(ws, session)) { + return; + } let connectionDetails: ConnectConfig = {}; diff --git a/packages/server/src/services/known-secrets.ts b/packages/server/src/services/known-secrets.ts new file mode 100644 index 00000000000..5108174ed74 --- /dev/null +++ b/packages/server/src/services/known-secrets.ts @@ -0,0 +1,160 @@ +import { db } from "@dokploy/server/db"; +import { + applications, + compose, + environments, + libsql, + mariadb, + mongo, + mysql, + postgres, + projects, + redis, + registry, +} from "@dokploy/server/db/schema"; +import { collectComposeSecretValues } from "@dokploy/server/utils/security/compose-secrets"; +import { + buildKnownSecretMatcher, + collectEnvSecretValues, + collectSecretValue, + type KnownSecretMatcher, +} from "@dokploy/server/utils/security/secret-scrub"; + +// A secret saved a moment ago only reaches a container after a redeploy, so a +// short-lived cache cannot let it slip through a log read. +const CACHE_TTL_MS = 30_000; + +let cached: { matcher: KnownSecretMatcher; expiresAt: number } | null = null; +let pending: Promise | null = null; + +const loadKnownSecretValues = async () => { + const [ + applicationRows, + composeRows, + postgresRows, + mysqlRows, + mariadbRows, + mongoRows, + redisRows, + libsqlRows, + projectRows, + environmentRows, + registryRows, + ] = await Promise.all([ + db + .select({ + env: applications.env, + previewEnv: applications.previewEnv, + buildArgs: applications.buildArgs, + buildSecrets: applications.buildSecrets, + previewBuildArgs: applications.previewBuildArgs, + previewBuildSecrets: applications.previewBuildSecrets, + password: applications.password, + }) + .from(applications), + db + .select({ env: compose.env, composeFile: compose.composeFile }) + .from(compose), + db + .select({ env: postgres.env, password: postgres.databasePassword }) + .from(postgres), + db + .select({ + env: mysql.env, + password: mysql.databasePassword, + rootPassword: mysql.databaseRootPassword, + }) + .from(mysql), + db + .select({ + env: mariadb.env, + password: mariadb.databasePassword, + rootPassword: mariadb.databaseRootPassword, + }) + .from(mariadb), + db.select({ env: mongo.env, password: mongo.databasePassword }).from(mongo), + db.select({ env: redis.env, password: redis.databasePassword }).from(redis), + db + .select({ env: libsql.env, password: libsql.databasePassword }) + .from(libsql), + db.select({ env: projects.env }).from(projects), + db.select({ env: environments.env }).from(environments), + db.select({ password: registry.password }).from(registry), + ]); + + const values: string[] = []; + const addEnv = (...texts: (string | null | undefined)[]) => { + for (const text of texts) { + values.push(...collectEnvSecretValues(text)); + } + }; + const addPassword = (...passwords: (string | null | undefined)[]) => { + for (const password of passwords) { + values.push(...collectSecretValue("PASSWORD", password)); + } + }; + + for (const row of applicationRows) { + addEnv( + row.env, + row.previewEnv, + row.buildArgs, + row.buildSecrets, + row.previewBuildArgs, + row.previewBuildSecrets, + ); + addPassword(row.password); + } + for (const row of composeRows) { + addEnv(row.env); + values.push(...collectComposeSecretValues(row.composeFile)); + } + for (const row of [...projectRows, ...environmentRows]) { + addEnv(row.env); + } + for (const row of [ + ...postgresRows, + ...mongoRows, + ...redisRows, + ...libsqlRows, + ]) { + addEnv(row.env); + addPassword(row.password); + } + for (const row of [...mysqlRows, ...mariadbRows]) { + addEnv(row.env); + addPassword(row.password, row.rootPassword); + } + for (const row of registryRows) { + addPassword(row.password); + } + + return values; +}; + +/** + * Every secret value the panel stores for services, projects and environments, + * compiled into one matcher used to scrub API key responses. It covers the + * whole instance: scrubbing another organization's secret is harmless, missing + * one is not. + */ +export const getKnownSecretMatcher = async (): Promise => { + if (cached && cached.expiresAt > Date.now()) { + return cached.matcher; + } + pending ??= loadKnownSecretValues() + .then((values) => { + const matcher = buildKnownSecretMatcher(values); + cached = { matcher, expiresAt: Date.now() + CACHE_TTL_MS }; + return matcher; + }) + .finally(() => { + pending = null; + }); + return pending; +}; + +export const resetKnownSecretMatcherCache = () => { + cached = null; + pending = null; +}; diff --git a/packages/server/src/utils/security/compose-secrets.ts b/packages/server/src/utils/security/compose-secrets.ts new file mode 100644 index 00000000000..e28a8704db8 --- /dev/null +++ b/packages/server/src/utils/security/compose-secrets.ts @@ -0,0 +1,531 @@ +import { + type Document, + isAlias, + isMap, + isScalar, + isSeq, + type Node, + parseDocument, + type Scalar, + visit, + type YAMLMap, +} from "yaml"; +import { + isSecretLookingName, + isVariableReference, + REDACTED_SECRET_VALUE, +} from "./redaction"; +import { + collectSecretValue, + findSecretTokens, + hasUnrestorablePlaceholder, + type KnownSecretMatcher, + replaceSecretTokens, + scrubSecretText, + secretTokenFor, +} from "./secret-scrub"; + +/** + * A compose file is the one text an agent has to read whole and send back + * edited, so its secrets are hidden by position rather than by guessing: every + * literal value in an `environment` or build `args` block and every inline + * secret or config body is replaced, whatever the variable is called. Each + * hidden value becomes a token, and a file sent back with its tokens gets the + * values put back. + */ + +type SecretReplacement = (secret: string) => string; +type Edit = { start: number; end: number; text: string }; +type SlotKind = "value" | "assignment"; + +const PLAIN_SAFE = /^[A-Za-z0-9_./:@=,+-]+$/; + +const isHidden = (value: string) => value.includes(REDACTED_SECRET_VALUE); + +const isLiteral = (value: string) => + value !== "" && !isVariableReference(value) && !isHidden(value); + +const resolveNode = (document: Document, node: unknown): Node | undefined => { + if (isAlias(node)) { + return node.resolve(document); + } + return node === null || node === undefined ? undefined : (node as Node); +}; + +// A key may come from a merged anchor (`<<: *defaults`), so each map is looked +// up together with everything merged into it. +const findInMap = ( + document: Document, + map: YAMLMap, + key: string, + seen = new Set(), +): Node[] => { + if (seen.has(map)) { + return []; + } + seen.add(map); + const found: Node[] = []; + for (const pair of map.items) { + const name = isScalar(pair.key) ? pair.key.value : undefined; + if (name === key) { + const value = resolveNode(document, pair.value); + if (value) { + found.push(value); + } + } else if (name === "<<") { + const merged = resolveNode(document, pair.value); + const sources = isSeq(merged) ? merged.items : [merged]; + for (const source of sources) { + const target = resolveNode(document, source); + if (isMap(target)) { + found.push(...findInMap(document, target, key, seen)); + } + } + } + } + return found; +}; + +const collectVariableBlock = ( + document: Document, + block: Node | undefined, + slots: Map, + seen = new Set(), +) => { + if (!block || seen.has(block)) { + return; + } + seen.add(block); + if (isMap(block)) { + for (const pair of block.items) { + if (isScalar(pair.key) && pair.key.value === "<<") { + const merged = resolveNode(document, pair.value); + for (const source of isSeq(merged) ? merged.items : [merged]) { + collectVariableBlock( + document, + resolveNode(document, source), + slots, + seen, + ); + } + continue; + } + const value = resolveNode(document, pair.value); + if (isScalar(value)) { + slots.set(value, "value"); + } + } + } else if (isSeq(block)) { + for (const item of block.items) { + const value = resolveNode(document, item); + if (isScalar(value)) { + slots.set(value, "assignment"); + } + } + } +}; + +const collectSlots = (document: Document) => { + const slots = new Map(); + const root = document.contents; + if (!isMap(root)) { + return slots; + } + + for (const services of findInMap(document, root, "services")) { + if (!isMap(services)) { + continue; + } + for (const pair of services.items) { + const service = resolveNode(document, pair.value); + if (!isMap(service)) { + continue; + } + for (const block of findInMap(document, service, "environment")) { + collectVariableBlock(document, block, slots); + } + for (const build of findInMap(document, service, "build")) { + if (isMap(build)) { + for (const block of findInMap(document, build, "args")) { + collectVariableBlock(document, block, slots); + } + } + } + } + } + + for (const section of ["secrets", "configs"]) { + for (const entries of findInMap(document, root, section)) { + if (!isMap(entries)) { + continue; + } + for (const pair of entries.items) { + const entry = resolveNode(document, pair.value); + if (!isMap(entry)) { + continue; + } + for (const content of findInMap(document, entry, "content")) { + if (isScalar(content)) { + slots.set(content, "value"); + } + } + } + } + } + + return slots; +}; + +const formatScalar = (node: Scalar, text: string) => { + if (node.type === "PLAIN" && PLAIN_SAFE.test(text)) { + return text; + } + const quoted = JSON.stringify(text); + // A block scalar ends with its line break, a quoted one does not. + return node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED" + ? `${quoted}\n` + : quoted; +}; + +const applyEdits = (text: string, edits: Edit[]) => { + let result = text; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`; + } + return result; +}; + +const parseCompose = (text: string) => { + const document = parseDocument(text, { merge: false, uniqueKeys: false }); + return document.errors.length > 0 ? null : document; +}; + +type ScalarVisit = { + node: Scalar; + position: "key" | "value" | "item"; + parentKey: string | null; + // Inside `labels`, `command` and the like, where a secret has no name. + freeForm: boolean; +}; + +const FREE_FORM_BLOCKS = new Set([ + "annotations", + "command", + "entrypoint", + "labels", + "test", +]); + +const visitScalars = ( + document: Document, + visitor: (scalar: ScalarVisit & { range: [number, number] }) => void, +) => { + const ranges: [number, number][] = []; + visit(document, { + Scalar(key, node, path) { + if (!node.range) { + return; + } + const range: [number, number] = [node.range[0], node.range[1]]; + ranges.push(range); + const parent = path[path.length - 1]; + const pairKey = + key === "value" && parent && "key" in parent && isScalar(parent.key) + ? String(parent.key.value) + : null; + visitor({ + node, + range, + position: key === "key" ? "key" : key === "value" ? "value" : "item", + parentKey: pairKey, + freeForm: + key !== "key" && + path.some( + (ancestor) => + "key" in ancestor && + isScalar(ancestor.key) && + FREE_FORM_BLOCKS.has(String(ancestor.key.value)), + ), + }); + }, + }); + return ranges; +}; + +// Whatever lies outside the scalars is structure and comments, so a `#` there +// always starts a comment. +const commentRanges = (text: string, scalarRanges: [number, number][]) => { + const comments: [number, number][] = []; + const boundaries: [number, number][] = [ + ...[...scalarRanges].sort((a, b) => a[0] - b[0]), + [text.length, text.length], + ]; + let cursor = 0; + for (const [start, end] of boundaries) { + if (start > cursor) { + const gap = text.slice(cursor, start); + for (const match of gap.matchAll(/#[^\n]*/g)) { + comments.push([ + cursor + match.index, + cursor + match.index + match[0].length, + ]); + } + } + cursor = Math.max(cursor, end); + } + return comments; +}; + +const hideScalar = ( + { node, position, parentKey, freeForm }: ScalarVisit, + slot: SlotKind | undefined, + matcher: KnownSecretMatcher, + replacement: SecretReplacement, +): string | null => { + const value = node.value; + if (value === null || value === undefined || typeof value === "boolean") { + return null; + } + const text = String(value); + + if (slot === "assignment") { + const separator = text.indexOf("="); + const literal = separator === -1 ? "" : text.slice(separator + 1); + return isLiteral(literal) + ? `${text.slice(0, separator + 1)}${replacement(literal)}` + : null; + } + if ( + slot === "value" || + (position === "value" && + parentKey !== null && + isSecretLookingName(parentKey)) + ) { + return isLiteral(text) ? replacement(text) : null; + } + if (typeof value !== "string") { + return null; + } + const scrubbed = scrubSecretText(text, matcher, { + replacement, + words: freeForm, + }); + return scrubbed === text ? null : scrubbed; +}; + +export const hideComposeSecrets = ( + text: string, + matcher: KnownSecretMatcher, + replacement: SecretReplacement = secretTokenFor, +) => { + if (text.trim() === "") { + return text; + } + const document = parseCompose(text); + // A file that does not parse has no positions to go by, and what cannot be + // placed cannot be shown. + if (!document) { + return REDACTED_SECRET_VALUE; + } + + const slots = collectSlots(document); + const edits: Edit[] = []; + const scalarRanges = visitScalars(document, (scalar) => { + const hidden = hideScalar( + scalar, + slots.get(scalar.node), + matcher, + replacement, + ); + if (hidden !== null) { + edits.push({ + start: scalar.range[0], + end: scalar.range[1], + text: formatScalar(scalar.node, hidden), + }); + } + }); + for (const [start, end] of commentRanges(text, scalarRanges)) { + const comment = text.slice(start, end); + const scrubbed = scrubSecretText(comment, matcher, { replacement }); + if (scrubbed !== comment) { + edits.push({ start, end, text: scrubbed }); + } + } + return applyEdits(text, edits); +}; + +// A secret written into the compose file itself is as real as one stored in +// env, so it joins the values scrubbed out of logs and other text. +export const collectComposeSecretValues = ( + text: string | null | undefined, +): string[] => { + const document = text?.trim() ? parseCompose(text) : null; + if (!document) { + return []; + } + const slots = collectSlots(document); + const values: string[] = []; + visitScalars(document, ({ node, parentKey }) => { + const slot = slots.get(node); + if (!slot || node.value === null || node.value === undefined) { + return; + } + const text = String(node.value); + const separator = text.indexOf("="); + if (slot === "value") { + values.push(...collectSecretValue(parentKey ?? "", text)); + } else if (separator !== -1) { + values.push( + ...collectSecretValue( + text.slice(0, separator), + text.slice(separator + 1), + ), + ); + } + }); + return values; +}; + +export class SecretRestoreError extends Error {} + +const countTokens = (text: string) => { + const counts = new Map(); + for (const token of findSecretTokens(text)) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + return counts; +}; + +// A value may only come back where it is hidden again. Otherwise moving a +// token next to `image:` would turn the secret into readable text on the next +// read. +const assertStillHidden = (submitted: string, rehidden: string) => { + const visible = countTokens(rehidden); + for (const [token, count] of countTokens(submitted)) { + if ((visible.get(token) ?? 0) < count) { + throw new SecretRestoreError( + "A hidden value was moved to a place where it would become readable. Keep tokens inside environment variables, build args or the line they came from", + ); + } + } +}; + +const buildVault = ( + hide: (replacement: SecretReplacement) => string, +): Map => { + const vault = new Map(); + hide((secret) => { + const token = secretTokenFor(secret); + vault.set(token, secret); + return token; + }); + return vault; +}; + +// A hidden value may itself hold a token (a line hidden whole after a +// password inside it was), so a token is resolved all the way down. +const resolveToken = (vault: Map) => { + const resolve = (token: string, depth = 0): string => { + const value = vault.get(token); + if (value === undefined || depth > 8) { + throw new SecretRestoreError( + "The text contains a token that does not belong to the stored version. Read it again and keep its tokens unchanged", + ); + } + return replaceSecretTokens(value, (inner) => resolve(inner, depth + 1)); + }; + return (token: string) => resolve(token); +}; + +const assertRestorable = (submitted: string) => { + if (hasUnrestorablePlaceholder(submitted)) { + throw new SecretRestoreError( + "The text contains a redacted placeholder without a token, so the hidden value cannot be put back. Replace it with a variable reference or a new value", + ); + } +}; + +/** + * Puts the stored values back where a compose file sent by an API key carries + * their tokens. + */ +export const restoreComposeSecrets = ( + submitted: string, + stored: string | null | undefined, + matcher: KnownSecretMatcher, +) => { + assertRestorable(submitted); + if (findSecretTokens(submitted).length === 0) { + return submitted; + } + const document = parseCompose(submitted); + if (!document) { + throw new SecretRestoreError( + "The compose file is not valid YAML, so its hidden values cannot be put back", + ); + } + + const resolve = resolveToken( + buildVault((replacement) => + hideComposeSecrets(stored ?? "", matcher, replacement), + ), + ); + const edits: Edit[] = []; + const scalarRanges = visitScalars(document, ({ node, range }) => { + if ( + typeof node.value === "string" && + findSecretTokens(node.value).length > 0 + ) { + edits.push({ + start: range[0], + end: range[1], + text: formatScalar(node, replaceSecretTokens(node.value, resolve)), + }); + } + }); + for (const [start, end] of commentRanges(submitted, scalarRanges)) { + const comment = submitted.slice(start, end); + if (findSecretTokens(comment).length > 0) { + edits.push({ start, end, text: replaceSecretTokens(comment, resolve) }); + } + } + + const restored = applyEdits(submitted, edits); + if (findSecretTokens(restored).length > 0) { + throw new SecretRestoreError( + "A token stands where a value cannot be placed. Keep tokens inside values", + ); + } + assertStillHidden(submitted, hideComposeSecrets(restored, matcher)); + return restored; +}; + +/** + * The same for a file whose format is not known (a mounted config file): the + * value goes back exactly where its token stands. + */ +export const restoreTextSecrets = ( + submitted: string, + stored: string | null | undefined, + matcher: KnownSecretMatcher, +) => { + assertRestorable(submitted); + if (findSecretTokens(submitted).length === 0) { + return submitted; + } + const resolve = resolveToken( + buildVault((replacement) => + scrubSecretText(stored ?? "", matcher, { replacement, file: true }), + ), + ); + const restored = replaceSecretTokens(submitted, resolve); + assertStillHidden( + submitted, + scrubSecretText(restored, matcher, { + replacement: secretTokenFor, + file: true, + }), + ); + return restored; +}; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 88fea520436..ddca8b0c669 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -21,6 +21,10 @@ export const isSecretPlaceholderValue = (value: unknown) => (value.includes(REDACTED_SECRET_VALUE) || value.includes(MCP_REDACTED_SECRET_VALUE)); +// Callers send a redacted field back unchanged to mean "keep what is stored". +export const isExactSecretPlaceholder = (value: unknown) => + value === REDACTED_SECRET_VALUE || value === MCP_REDACTED_SECRET_VALUE; + export const redactSecretValue = (value: T) => { if (value === null || value === undefined || value === "") { return value; @@ -392,66 +396,271 @@ export const redactProjectNestedSecrets = < return redactedProject as T; }; -export function redactSensitiveText(value: string): string; -export function redactSensitiveText(value: null): null; -export function redactSensitiveText(value: undefined): undefined; -export function redactSensitiveText(value: string | null): string | null; +type KeyPatterns = { + beforeAuthorization: RegExp[]; + afterAuthorization: RegExp[]; +}; + +const DEFAULT_KEY_PATTERNS: KeyPatterns = { + beforeAuthorization: [ + new RegExp( + `([?&#][^=\\s]*${SENSITIVE_KEY_PATTERN}[^=\\s]*=)([^&#\\s]+)`, + "gi", + ), + new RegExp( + `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)(${SHELL_VALUE_PATTERN})`, + "gi", + ), + ], + afterAuthorization: [ + new RegExp( + `((?:"[^"]*${SENSITIVE_KEY_PATTERN}[^"]*"|'[^']*${SENSITIVE_KEY_PATTERN}[^']*'|\\b[^\\s:,{]*${SENSITIVE_KEY_PATTERN}[^\\s:,{]*\\b)\\s*:\\s*)("[^"]*"|'[^']*'|[^\\s,}\\]]+)`, + "gi", + ), + // Negated flags such as pg_dump's `--no-password` take no value, so the + // word after them is a positional argument (the database name), not a + // secret. + new RegExp( + `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))(${SHELL_VALUE_PATTERN})`, + "gi", + ), + ], +}; + +// Env-style names that hold secrets without saying "secret" or "token". +const BROAD_SENSITIVE_KEY_PATTERN = + "(?:access[_-]?key|api[_-]?key|auth|credential|private[_-]?key|secret|token|pass|pwd|dsn|cookie|session|signature|salt|webhook|license|encrypt|jwt|[_-]key)"; + +// A `${...}` value is taken whole, so redacting it leaves no stray brace. +const BRACED_VALUE_PATTERN = String.raw`(?:\$\{\{[^}\s]*\}\}|\$\{[^}\s]*\})+`; +const KEY_VALUE_PATTERN = `${BRACED_VALUE_PATTERN}|"[^"]*"|'[^']*'|[^\\s,}\\]]+`; + +// What follows `name:` in an image reference or a host is a tag or a port. +const TAG_OR_PORT_PATTERN = String.raw`(?:\d{1,5}|v?\d+(?:\.\d+)+[\w.-]*|latest|stable|main|master|canary|edge|nightly)(?:[/\s"',)\]]|$)`; + +// The broad patterns run over whole logs and files, so each one anchors on the +// keyword and bounds what surrounds it: scanning a free prefix from every word +// boundary is quadratic on a long line without spaces. A key also has to look +// like a key with a scalar value on the same line. Otherwise the short +// keywords would read `auth-db:5432` in a URL, the image `auth-api:1.2.3` or +// the YAML mapping `auth-router:` as a secret. +const KEY_NAME_HEAD = String.raw`[\w.-]{0,60}?`; +const KEY_NAME_TAIL = String.raw`[\w.-]{0,80}`; +const BROAD_KEY = `${BROAD_SENSITIVE_KEY_PATTERN}${KEY_NAME_TAIL}`; +// `==`, `=>` and `=~` compare or map; they do not assign. +const ASSIGN = String.raw`[ \t]*=(?![=>~])[ \t]*`; + +const BROAD_KEY_PATTERNS: KeyPatterns = { + beforeAuthorization: [ + // A line that is one assignment: the value runs to the end of the line, + // spaces included (`password = correct horse`). + new RegExp( + `^([ \\t]*(?:(?:export|set|setenv)[ \\t]+)?["']?${KEY_NAME_HEAD}${BROAD_KEY}["']?${ASSIGN})(\\S.*)$`, + "gim", + ), + new RegExp( + `(${BROAD_SENSITIVE_KEY_PATTERN}[\\w.\\[\\]"'-]{0,80}${ASSIGN})(${SHELL_VALUE_PATTERN})`, + "gi", + ), + new RegExp( + `(${BROAD_KEY}["']?[ \\t]*=>[ \\t]*)("[^"]*"|'[^']*'|[^\\s,;)\\]}]+)`, + "gi", + ), + new RegExp( + `(<[\\w:.-]{0,60}?${BROAD_SENSITIVE_KEY_PATTERN}[\\w:.-]{0,80}>)([^<\\n]+)`, + "gi", + ), + ], + afterAuthorization: [ + new RegExp( + `(${BROAD_SENSITIVE_KEY_PATTERN}[^"'\\n]{0,80}["'][ \\t]*:[ \\t]*)(?![{[])(${KEY_VALUE_PATTERN})`, + "gi", + ), + // `key: value` on its own line, the value again running to the end. + new RegExp( + `^([ \\t]*(?:-[ \\t]+)?${KEY_NAME_HEAD}${BROAD_KEY}[ \\t]*:[ \\t]+)(?!["'{[|>&*!])(\\S.*)$`, + "gim", + ), + new RegExp( + `(?<=(?)(\\S.*?)(;?[ \\t\\r]*)$`, + "gim", +); + +// A generated credential is a long run of letters and digits with nothing that +// marks a host, a path or a URL. It is told apart from a name such as +// `backend-prod-eu-01` by mixed case, several digits, or no separators at all. +const OPAQUE_RUN = "[A-Za-z0-9_+-]{16,}={0,2}"; +const WHOLE_OPAQUE_RUN = new RegExp(`^${OPAQUE_RUN}$`); + +export const isOpaqueValue = (value: string) => + WHOLE_OPAQUE_RUN.test(value) && + /[A-Za-z]/.test(value) && + /\d/.test(value) && + ((/[a-z]/.test(value) && /[A-Z]/.test(value)) || + (value.match(/\d/g)?.length ?? 0) >= 4 || + !/[-_]/.test(value)); + +// In a file a setting may hold such a value under any name at all. +const OPAQUE_SETTING_PATTERN = new RegExp( + `^([ \\t]*(?:-[ \\t]+)?["']?[\\w.-]{1,80}["']?[ \\t]*(?:=(?![=>~])|:[ \\t])[ \\t]*["']?)(${OPAQUE_RUN})(?=["']?[ \\t\\r]*[;,]?[ \\t\\r]*$)`, + "gm", +); + +// A word of a command line or a label that looks generated. +const OPAQUE_WORD_PATTERN = new RegExp( + `(^|[\\s="'])(${OPAQUE_RUN})(?=$|[\\s"'])`, + "g", +); + +const SECRET_LOOKING_NAME = new RegExp(BROAD_SENSITIVE_KEY_PATTERN, "i"); + +export const isSecretLookingName = (name: string) => + SECRET_LOOKING_NAME.test(name); + +const VARIABLE_REFERENCE_PATTERN = String.raw`\$\{\{\s*[A-Za-z_][\w.-]*\s*\}\}|\$\{[A-Za-z_]\w*(?::?\?[^}]*)?\}|\$[A-Za-z_]\w*`; +const VARIABLE_REFERENCE_VALUE = new RegExp( + `^(?:${VARIABLE_REFERENCE_PATTERN})+$`, +); + +// `${VAR}`, `$VAR` and `${{project.VAR}}` name a secret without containing it. +// A reference with an inline default (`${VAR:-fallback}`) is not one of them. +export const isVariableReference = (value: string) => { + const quoted = /^(["'])([\s\S]*)\1$/.exec(value); + return VARIABLE_REFERENCE_VALUE.test(quoted ? quoted[2]! : value); +}; + +const isVariableReferenceUserinfo = (userinfo: string) => + userinfo.split(":").every((part) => isVariableReference(part)); + +// A restorable placeholder: the plain placeholder followed by an id of the +// hidden value. The id lets the same caller send the text back and have the +// value put back where the token stands. +export const SECRET_TOKEN_PATTERN = `${REDACTED_SECRET_VALUE}[A-Z2-7]{12}__`; +const ALREADY_HIDDEN_VALUE = new RegExp( + `^["']?${REDACTED_SECRET_VALUE}(?:[A-Z2-7]{12}__)?["']?$`, +); + +export type RedactSensitiveTextOptions = { + keepVariableReferences?: boolean; + broadKeys?: boolean; + // Read `name value` lines as settings; for files, not for logs. + directives?: boolean; + // Also hide generated-looking values under any name: `file` for the + // settings of a config file, `words` for the words of a command or label. + opaque?: "file" | "words"; + // Produces what stands in for a secret; defaults to the plain placeholder. + replacement?: (secret: string) => string; +}; + +export function redactSensitiveText( + value: string, + options?: RedactSensitiveTextOptions, +): string; +export function redactSensitiveText( + value: null, + options?: RedactSensitiveTextOptions, +): null; +export function redactSensitiveText( + value: undefined, + options?: RedactSensitiveTextOptions, +): undefined; +export function redactSensitiveText( + value: string | null, + options?: RedactSensitiveTextOptions, +): string | null; export function redactSensitiveText( value: string | undefined, + options?: RedactSensitiveTextOptions, ): string | undefined; export function redactSensitiveText( value: string | null | undefined, + options?: RedactSensitiveTextOptions, ): string | null | undefined; -export function redactSensitiveText(value: string | null | undefined) { +export function redactSensitiveText( + value: string | null | undefined, + options: RedactSensitiveTextOptions = {}, +) { if (typeof value !== "string" || value === "") { return value; } - let redacted = value; + const { replacement, keepVariableReferences } = options; + const keyPatterns = options.broadKeys + ? BROAD_KEY_PATTERNS + : DEFAULT_KEY_PATTERNS; + // A token names its value, so a hidden value met again has to stay as it is: + // a token of a token could not be resolved. + const hide = (secret: string) => { + if (!replacement) { + return REDACTED_SECRET_VALUE; + } + return ALREADY_HIDDEN_VALUE.test(secret) ? secret : replacement(secret); + }; + const hideValue = (match: string, prefix: string, secret: string) => + keepVariableReferences && isVariableReference(secret) + ? match + : `${prefix}${hide(secret)}`; + const hideRest = (match: string, prefix: string) => + `${prefix}${hide(match.slice(prefix.length))}`; - redacted = redacted.replace( - /(\b[a-z][a-z0-9+.-]*:\/\/)([^@\s/?#]+)@/gi, - `$1${REDACTED_SECRET_VALUE}@`, - ); + let redacted = value; redacted = redacted.replace( - new RegExp( - `([?&#][^=\\s]*${SENSITIVE_KEY_PATTERN}[^=\\s]*=)([^&#\\s]+)`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, + /(\b[a-z][a-z0-9+.-]{0,30}:\/\/)([^@\s/?#]+)@/gi, + (match, scheme: string, userinfo: string) => + keepVariableReferences && isVariableReferenceUserinfo(userinfo) + ? match + : `${scheme}${hide(userinfo)}@`, ); - redacted = redacted.replace( - new RegExp( - `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)(${SHELL_VALUE_PATTERN})`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, - ); + for (const pattern of keyPatterns.beforeAuthorization) { + redacted = redacted.replace(pattern, hideValue); + } redacted = redacted.replace( /(\bAuthorization\s*:\s*)(?:Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, - `$1${REDACTED_SECRET_VALUE}`, + hideRest, ); - redacted = redacted.replace( - new RegExp( - `((?:"[^"]*${SENSITIVE_KEY_PATTERN}[^"]*"|'[^']*${SENSITIVE_KEY_PATTERN}[^']*'|\\b[^\\s:,{]*${SENSITIVE_KEY_PATTERN}[^\\s:,{]*\\b)\\s*:\\s*)("[^"]*"|'[^']*'|[^\\s,}\\]]+)`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, - ); + for (const pattern of keyPatterns.afterAuthorization) { + redacted = redacted.replace(pattern, hideValue); + } - // Negated flags such as pg_dump's `--no-password` take no value, so the word - // after them is a positional argument (the database name), not a secret. - redacted = redacted.replace( - new RegExp( - `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))(${SHELL_VALUE_PATTERN})`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, - ); + if (options.directives) { + redacted = redacted.replace( + DIRECTIVE_PATTERN, + (match, prefix: string, secret: string, end: string) => + keepVariableReferences && isVariableReference(secret) + ? match + : `${prefix}${hide(secret)}${end}`, + ); + } + + if (options.opaque) { + redacted = redacted.replace( + options.opaque === "file" ? OPAQUE_SETTING_PATTERN : OPAQUE_WORD_PATTERN, + (match, prefix: string, secret: string) => + isOpaqueValue(secret) ? `${prefix}${hide(secret)}` : match, + ); + } // mysql/mariadb clients take the password glued to -p. A separate word after // -p is not a password, uppercase -P is the port, and -p of other tools @@ -461,43 +670,43 @@ export function redactSensitiveText(value: string | null | undefined) { String.raw`(\b(?:mysql|mariadb)[a-z-]*(?:\\.|[^\n;&|\\])*?\s-p)(${SHELL_VALUE_PATTERN})`, "g", ), - `$1${REDACTED_SECRET_VALUE}`, + hideRest, ); redacted = redacted.replace( - /(\b(? + keepVariableReferences && isVariableReference(password) + ? match + : `${prefix}${hide(password)}${at}`, ); redacted = redacted.replace( - /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, - `$1 ${REDACTED_SECRET_VALUE}`, + /\b(Bearer|Basic)\s+([A-Za-z0-9._~+/-]+=*)/gi, + (_match, scheme: string, token: string) => `${scheme} ${hide(token)}`, ); - redacted = redacted.replace( - /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, - REDACTED_SECRET_VALUE, - ); + redacted = redacted.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, hide); return redacted; } diff --git a/packages/server/src/utils/security/secret-output.ts b/packages/server/src/utils/security/secret-output.ts new file mode 100644 index 00000000000..d6e3affa222 --- /dev/null +++ b/packages/server/src/utils/security/secret-output.ts @@ -0,0 +1,113 @@ +import { hideComposeSecrets } from "./compose-secrets"; +import { REDACTED_SECRET_VALUE } from "./redaction"; +import { + EMPTY_KNOWN_SECRET_MATCHER, + type KnownSecretMatcher, + scrubSecretText, + secretTokenFor, +} from "./secret-scrub"; + +/** + * An API key response never carries secret values: fields that hold secrets + * are blanked, compose files keep their structure with every value hidden + * behind a restorable token, and every other string (logs, mounted files, + * commands) is scrubbed as text. + */ + +const SENSITIVE_OUTPUT_KEY = + /(?:env$|buildargs$|buildsecrets$|password|passwd|passphrase|secret|tokens?$|private_?key|api_?key$|access_?key|credential|authorization|^cookie$|dsn$|connection_?string|webhook_?url$|^dockerauth$|license_?key$|^userkey$|^headers$)/i; + +// `secretName`, `apiKeyId` or `tokenType` describe a secret without holding it. +const DESCRIPTIVE_KEY_SUFFIX = + /(?:Id|Ids|Name|Names|Path|Type|Count|At|Enabled|Status|Length|Prefix)$/; + +// Identifiers, revisions and image references are never secrets, and a caller +// that gets one back mangled can no longer address the resource or pass the +// revision on. +const IDENTITY_KEY = + /(?:^id$|Id$|Ids$|^name$|Name$|^revision$|Revision$|^image$|Image$)/; + +export const isSensitiveOutputKey = (key: string) => + SENSITIVE_OUTPUT_KEY.test(key) && !DESCRIPTIVE_KEY_SUFFIX.test(key); + +// Text the caller may edit and send back: its hidden values carry a token, so +// the panel can put them back on the matching write. +const COMPOSE_TEXT_KEY = /^composeFile$/; +const FILE_TEXT_KEY = /^content$/; + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/; + +// Under a secret-bearing key every value is blanked. The names in a list of +// assignments (a docker `Env` array) and the keys of a map stay readable. +const blankSecretValue = (value: unknown, listItem = false): unknown => { + if (typeof value === "string") { + if (value === "") { + return value; + } + const assignment = listItem ? ENV_ASSIGNMENT.exec(value) : null; + return assignment + ? `${assignment[1]}=${REDACTED_SECRET_VALUE}` + : REDACTED_SECRET_VALUE; + } + if (Array.isArray(value)) { + return value.map((item) => blankSecretValue(item, true)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, blankSecretValue(item)]), + ); + } + return value; +}; + +const scrubValue = ( + value: unknown, + key: string | null, + matcher: KnownSecretMatcher, +): unknown => { + if (typeof value === "string") { + if (key === null) { + return scrubSecretText(value, matcher); + } + if (IDENTITY_KEY.test(key)) { + return value; + } + if (COMPOSE_TEXT_KEY.test(key)) { + return hideComposeSecrets(value, matcher, secretTokenFor); + } + return scrubSecretText( + value, + matcher, + FILE_TEXT_KEY.test(key) + ? { replacement: secretTokenFor, file: true } + : undefined, + ); + } + if (Array.isArray(value)) { + return value.map((item) => scrubValue(item, key, matcher)); + } + if (!isPlainObject(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([childKey, child]) => [ + childKey, + isSensitiveOutputKey(childKey) + ? blankSecretValue(child) + : scrubValue(child, childKey, matcher), + ]), + ); +}; + +export const scrubSecretOutput = ( + value: T, + matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, +): T => scrubValue(value, null, matcher) as T; diff --git a/packages/server/src/utils/security/secret-scrub.ts b/packages/server/src/utils/security/secret-scrub.ts new file mode 100644 index 00000000000..2e88060a60a --- /dev/null +++ b/packages/server/src/utils/security/secret-scrub.ts @@ -0,0 +1,322 @@ +import { createHmac } from "node:crypto"; +import { parse } from "dotenv"; +import { betterAuthSecret } from "../../lib/auth-secret"; +import { + isVariableReference, + REDACTED_SECRET_VALUE, + redactSensitiveText, + SECRET_TOKEN_PATTERN, +} from "./redaction"; + +/** + * Text that leaves the panel towards an AI (an API key response, a prompt for + * the AI provider) is scrubbed of the secret values the panel stores and of + * anything that looks like a credential. + */ + +export type KnownSecretMatcher = { + size: number; + pattern: RegExp | null; +}; + +export const EMPTY_KNOWN_SECRET_MATCHER: KnownSecretMatcher = { + size: 0, + pattern: null, +}; + +const SECRET_ENV_NAME = + /(?:^|_)(?:AUTH|CERT|CONNECTION_STRING|COOKIE|CREDENTIALS?|DSN|ENCRYPTION|JWT|KEYS?|LICENSE|MNEMONIC|PASS|PASSPHRASE|PASSWD|PASSWORD|PRIVATE|PW|PWD|SALT|SECRETS?|SEED|SESSION|SIGNATURE|TOKENS?|WEBHOOK)(?:$|_)/i; + +const MIN_KNOWN_SECRET_LENGTH = 8; +const OPAQUE_VALUE = /^[A-Za-z0-9+/_=-]{32,}$/; +const URL_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/([^@\s/?#]+)@/i; + +// A value only goes on the scrub list when replacing it everywhere cannot +// mangle ordinary output: dictionary-like words and plain numbers stay out. +const isDistinctiveValue = (value: string) => + value.length >= MIN_KNOWN_SECRET_LENGTH && + !/^[a-z]+$/.test(value) && + !/^\d+$/.test(value) && + !/\s/.test(value) && + !value.includes(REDACTED_SECRET_VALUE) && + !isVariableReference(value); + +const userinfoSecrets = (value: string) => { + const userinfo = URL_USERINFO.exec(value)?.[1]; + if (!userinfo) { + return []; + } + const separator = userinfo.indexOf(":"); + const secret = separator === -1 ? userinfo : userinfo.slice(separator + 1); + let decoded = secret; + try { + decoded = decodeURIComponent(secret); + } catch {} + return [...new Set([secret, decoded])]; +}; + +export const collectSecretValue = (name: string, value: unknown): string[] => { + if (typeof value !== "string") { + return []; + } + const trimmed = value.trim(); + const candidates = userinfoSecrets(trimmed); + if ( + SECRET_ENV_NAME.test(name) || + (OPAQUE_VALUE.test(trimmed) && + /[A-Za-z]/.test(trimmed) && + /\d/.test(trimmed)) + ) { + candidates.push(trimmed); + } + return candidates.filter(isDistinctiveValue); +}; + +export const collectEnvSecretValues = ( + envText: string | null | undefined, +): string[] => { + if (!envText) { + return []; + } + return Object.entries(parse(envText)).flatMap(([name, value]) => + collectSecretValue(name, value), + ); +}; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +type TrieNode = { children: Map; terminal: boolean }; + +// One alternation per secret makes the regex engine try every secret at every +// position. A prefix tree keeps a scan of a large log proportional to its size +// however many secrets the panel stores, and prefers the longest secret. +const toTriePattern = (node: TrieNode): string => { + const branches = [...node.children].map( + ([character, child]) => `${escapeRegExp(character)}${toTriePattern(child)}`, + ); + if (branches.length === 0) { + return ""; + } + if (node.terminal) { + return `(?:${branches.join("|")})?`; + } + return branches.length === 1 ? branches[0]! : `(?:${branches.join("|")})`; +}; + +const buildTriePattern = (values: Iterable) => { + const root: TrieNode = { children: new Map(), terminal: false }; + for (const value of values) { + let node = root; + for (const character of value) { + let child = node.children.get(character); + if (!child) { + child = { children: new Map(), terminal: false }; + node.children.set(character, child); + } + node = child; + } + node.terminal = true; + } + root.terminal = false; + return toTriePattern(root); +}; + +const MIN_ENCODED_FORM_LENGTH = 16; + +// A secret printed in base64 or hex is still the secret. Base64 text depends on +// where the value starts inside the encoded data, so each of the three +// alignments is covered, without the characters that mix in neighbouring bytes. +const encodedForms = (value: string) => { + const bytes = Buffer.from(value, "utf8"); + const hex = bytes.toString("hex"); + const forms = [hex, hex.toUpperCase()]; + for (let shift = 0; shift < 3; shift++) { + const encoded = Buffer.concat([Buffer.alloc(shift), bytes]) + .toString("base64") + .replace(/=+$/, ""); + const dangling = ((shift + bytes.length) * 8) % 6 === 0 ? 0 : 1; + const core = encoded.slice([0, 2, 3][shift], encoded.length - dangling); + forms.push(core, core.replace(/\+/g, "-").replace(/\//g, "_")); + } + return forms.filter((form) => form.length >= MIN_ENCODED_FORM_LENGTH); +}; + +export const buildKnownSecretMatcher = ( + values: Iterable, +): KnownSecretMatcher => { + const unique = [...new Set(values)].filter(isDistinctiveValue); + if (unique.length === 0) { + return EMPTY_KNOWN_SECRET_MATCHER; + } + return { + size: unique.length, + pattern: new RegExp( + buildTriePattern([...unique, ...unique.flatMap(encodedForms)]), + "g", + ), + }; +}; + +const TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +// A token is the placeholder plus an id of the hidden value. The id is keyed +// with a server secret, so it says nothing about the value, and it is the same +// each time the value is hidden, so a token read earlier still resolves. +export const secretTokenFor = (value: string) => { + const digest = createHmac("sha256", betterAuthSecret) + .update("secret-token-v1\0") + .update(value) + .digest(); + let id = ""; + for (let index = 0; index < 12; index++) { + id += TOKEN_ALPHABET[digest[index]! & 31]; + } + return `${REDACTED_SECRET_VALUE}${id}__`; +}; + +const SECRET_TOKEN = new RegExp(SECRET_TOKEN_PATTERN, "g"); +const WHOLE_SECRET_TOKEN = new RegExp(`^${SECRET_TOKEN_PATTERN}$`); +// The token comes first: a token starts with the plain placeholder. +const ANY_PLACEHOLDER = new RegExp( + `${SECRET_TOKEN_PATTERN}|${escapeRegExp(REDACTED_SECRET_VALUE)}|\\[REDACTED\\]`, + "g", +); + +export const findSecretTokens = (text: string) => + text.match(SECRET_TOKEN) ?? []; + +// A plain placeholder carries no id, so the value behind it cannot be put back. +export const hasUnrestorablePlaceholder = (text: string) => + (text.match(ANY_PLACEHOLDER) ?? []).some( + (placeholder) => !WHOLE_SECRET_TOKEN.test(placeholder), + ); + +export const replaceSecretTokens = ( + text: string, + resolve: (token: string) => string, +) => text.replace(SECRET_TOKEN, resolve); + +const PRIVATE_KEY_BLOCK = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g; + +// A token starts where the run of token characters starts, so a line made of +// one long run is scanned once instead of once per prefix it contains. +const TOKEN_START = "(? new RegExp(`${TOKEN_START}${shape}`, "g")); + +// Some credential patterns scan to the end of the line, so one huge line (a +// minified bundle, a base64 dump) is scrubbed in pieces to keep the cost of a +// log read proportional to its size. +const MAX_SCRUB_PIECE_LENGTH = 4096; + +const hasLongLine = (text: string) => { + let lineStart = 0; + while (lineStart <= text.length) { + const lineEnd = text.indexOf("\n", lineStart); + if ( + (lineEnd === -1 ? text.length : lineEnd) - lineStart > + MAX_SCRUB_PIECE_LENGTH + ) { + return true; + } + if (lineEnd === -1) { + return false; + } + lineStart = lineEnd + 1; + } + return false; +}; + +const PIECE_DELIMITER = /[\s,;&|]/; + +// Cutting after a delimiter keeps a `name=value` pair in one piece. +const pieceEnd = (line: string) => { + for ( + let index = MAX_SCRUB_PIECE_LENGTH; + index > MAX_SCRUB_PIECE_LENGTH / 2; + index-- + ) { + if (PIECE_DELIMITER.test(line[index - 1]!)) { + return index; + } + } + return MAX_SCRUB_PIECE_LENGTH; +}; + +const splitLongLine = (line: string) => { + const pieces: string[] = []; + let rest = line; + while (rest.length > MAX_SCRUB_PIECE_LENGTH) { + const end = pieceEnd(rest); + pieces.push(rest.slice(0, end)); + rest = rest.slice(end); + } + pieces.push(rest); + return pieces; +}; + +type SecretReplacement = (secret: string) => string; + +export type ScrubSecretTextOptions = { + // With it every hidden value becomes whatever it returns for that value, + // which is how restorable tokens are produced. + replacement?: SecretReplacement; + // The text is a config file, where `name value` lines are settings. + file?: boolean; + // The text is a command line or a label: hide its generated-looking words. + words?: boolean; +}; + +const scrubCredentialPatterns = ( + text: string, + { replacement, file, words }: ScrubSecretTextOptions, +) => { + const hide = replacement ?? (() => REDACTED_SECRET_VALUE); + let scrubbed = text; + for (const shape of TOKEN_SHAPES) { + scrubbed = scrubbed.replace(shape, (secret) => hide(secret)); + } + return redactSensitiveText(scrubbed, { + broadKeys: true, + keepVariableReferences: true, + directives: file, + opaque: file ? "file" : words ? "words" : undefined, + replacement, + }); +}; + +export const scrubSecretText = ( + text: string, + matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, + options: ScrubSecretTextOptions = {}, +) => { + if (text === "") { + return text; + } + const hide = options.replacement ?? (() => REDACTED_SECRET_VALUE); + const scrubbed = ( + matcher.pattern + ? text.replace(matcher.pattern, (secret) => hide(secret)) + : text + ).replace(PRIVATE_KEY_BLOCK, (secret) => hide(secret)); + if (!hasLongLine(scrubbed)) { + return scrubCredentialPatterns(scrubbed, options); + } + return scrubbed + .split("\n") + .map((line) => + splitLongLine(line) + .map((piece) => scrubCredentialPatterns(piece, options)) + .join(""), + ) + .join("\n"); +};