From 81afaf45e242ba56d41981d19ee53e1f6cb2590b Mon Sep 17 00:00:00 2001 From: Souvik Kumar Date: Thu, 13 Aug 2026 00:49:05 +0530 Subject: [PATCH] fix: redact registry password from build failure logs and emails The docker login command embeds the registry password, so when a deployment fails the command shows up in the error message that gets written to the deployment logs, sent in build-failure emails, and carried by the rethrown error into upstream log/queue handlers, leaking the password in plain text. Scrub the application's registry, build and rollback passwords from the error before it is logged, emailed, or rethrown, across the deploy, rebuild and preview paths. Fixes #4693 --- .../__test__/registry/redact-secrets.test.ts | 65 +++++++++++++++++++ packages/server/src/services/application.ts | 38 ++++++++--- packages/server/src/services/registry.ts | 52 ++++++++++++++- 3 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/__test__/registry/redact-secrets.test.ts diff --git a/apps/dokploy/__test__/registry/redact-secrets.test.ts b/apps/dokploy/__test__/registry/redact-secrets.test.ts new file mode 100644 index 000000000..1cb9bd824 --- /dev/null +++ b/apps/dokploy/__test__/registry/redact-secrets.test.ts @@ -0,0 +1,65 @@ +import { redactExecError, redactSecrets } from "@dokploy/server/services/registry"; +import { ExecError } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it } from "vitest"; + +describe("redactSecrets", () => { + it("replaces a secret with ***", () => { + const command = `printf %s 'sup3r-secret' | docker login registry.example.com -u user --password-stdin`; + expect(redactSecrets(command, ["sup3r-secret"])).toBe( + "printf %s '***' | docker login registry.example.com -u user --password-stdin", + ); + }); + + it("redacts every occurrence of a secret", () => { + expect(redactSecrets("pw=abc and again abc", ["abc"])).toBe( + "pw=*** and again ***", + ); + }); + + it("redacts multiple secrets", () => { + expect(redactSecrets("a then b", ["a", "b"])).toBe("*** then ***"); + }); + + it("ignores empty, null and undefined secrets", () => { + const text = "nothing to redact"; + expect(redactSecrets(text, ["", null, undefined])).toBe(text); + }); + + it("leaves the text untouched when the secret is absent", () => { + const text = "no secret here"; + expect(redactSecrets(text, ["other"])).toBe(text); + }); + + it("redacts a shell-escaped password", () => { + const escaped = "'a'\\''b'"; // shEscape("a'b") + const command = `printf %s ${escaped} | docker login`; + expect(redactSecrets(command, [escaped])).toBe( + "printf %s *** | docker login", + ); + }); +}); + +describe("redactExecError", () => { + it("redacts the secret from an ExecError message and command", () => { + const error = new ExecError("Command execution failed: pw=hunter2", { + command: "docker login -p hunter2", + stderr: "auth failed for hunter2", + }); + const redacted = redactExecError(error, ["hunter2"]); + expect(redacted).toBeInstanceOf(ExecError); + const execError = redacted as ExecError; + expect(execError.message).toBe("Command execution failed: pw=***"); + expect(execError.command).toBe("docker login -p ***"); + expect(execError.stderr).toBe("auth failed for ***"); + }); + + it("redacts the secret from a plain Error message", () => { + const error = new Error("failed with token abc123"); + const redacted = redactExecError(error, ["abc123"]) as Error; + expect(redacted.message).toBe("failed with token ***"); + }); + + it("returns non-error values unchanged", () => { + expect(redactExecError("just a string", ["x"])).toBe("just a string"); + }); +}); diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 0f2e5a4fc..fe0413fe8 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -39,6 +39,11 @@ import { updateDeploymentStatus, } from "./deployment"; import { type Domain, getDomainHost } from "./domain"; +import { + getRegistryPasswords, + redactExecError, + redactSecrets, +} from "./registry"; import { createPreviewDeploymentComment, getIssueComment, @@ -246,11 +251,17 @@ export const deployApplication = async ({ }); } catch (error) { let command = ""; + const passwords = await getRegistryPasswords([ + application.registryId, + application.buildRegistryId, + application.rollbackRegistryId, + ]); + const rawMessage = error instanceof Error ? error.message : String(error); + const safeMessage = redactSecrets(rawMessage, passwords); // Only log details for non-ExecError errors if (!(error instanceof ExecError)) { - const message = error instanceof Error ? error.message : String(error); - const encodedMessage = encodeBase64(message); + const encodedMessage = encodeBase64(safeMessage); command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`; } @@ -267,13 +278,12 @@ export const deployApplication = async ({ projectName: application.environment.project.name, applicationName: application.name, applicationType: "application", - // @ts-ignore - errorMessage: error?.message || "Error building", + errorMessage: safeMessage || "Error building", buildLink, organizationId: application.environment.project.organizationId, }); - throw error; + throw redactExecError(error, passwords); } finally { // Only extract commit info for non-docker sources if (application.sourceType !== "docker") { @@ -337,11 +347,16 @@ export const rebuildApplication = async ({ }); } catch (error) { let command = ""; + const passwords = await getRegistryPasswords([ + application.registryId, + application.buildRegistryId, + application.rollbackRegistryId, + ]); // Only log details for non-ExecError errors if (!(error instanceof ExecError)) { const message = error instanceof Error ? error.message : String(error); - const encodedMessage = encodeBase64(message); + const encodedMessage = encodeBase64(redactSecrets(message, passwords)); command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`; } @@ -353,7 +368,7 @@ export const rebuildApplication = async ({ } await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); - throw error; + throw redactExecError(error, passwords); } return true; @@ -579,11 +594,16 @@ export const rebuildPreviewApplication = async ({ }); } catch (error) { let command = ""; + const passwords = await getRegistryPasswords([ + application.registryId, + application.buildRegistryId, + application.rollbackRegistryId, + ]); // Only log details for non-ExecError errors if (!(error instanceof ExecError)) { const message = error instanceof Error ? error.message : String(error); - const encodedMessage = encodeBase64(message); + const encodedMessage = encodeBase64(redactSecrets(message, passwords)); command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`; } @@ -604,7 +624,7 @@ export const rebuildPreviewApplication = async ({ await updatePreviewDeployment(previewDeploymentId, { previewStatus: "error", }); - throw error; + throw redactExecError(error, passwords); } return true; diff --git a/packages/server/src/services/registry.ts b/packages/server/src/services/registry.ts index e395dd4c9..7176036fc 100644 --- a/packages/server/src/services/registry.ts +++ b/packages/server/src/services/registry.ts @@ -3,9 +3,10 @@ import { type apiCreateRegistry, registry } from "@dokploy/server/db/schema"; import { execAsync, execAsyncRemote, + ExecError, } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { z } from "zod"; import { IS_CLOUD } from "../constants"; @@ -37,6 +38,55 @@ function sanitizeRegistryError( return message.split(password).join("***"); } +export const redactSecrets = ( + text: string, + secrets: (string | null | undefined)[], +): string => { + let result = text; + for (const secret of secrets) { + if (secret) { + result = result.split(secret).join("***"); + } + } + return result; +}; + +export const getRegistryPasswords = async ( + registryIds: (string | null | undefined)[], +): Promise<(string | null)[]> => { + const uniqueIds = [...new Set(registryIds.filter((id): id is string => !!id))]; + if (uniqueIds.length === 0) { + return []; + } + const rows = await db.query.registry.findMany({ + where: inArray(registry.registryId, uniqueIds), + columns: { password: true }, + }); + return rows.flatMap((row) => + row.password ? [row.password, shEscape(row.password)] : [row.password], + ); +}; + +export const redactExecError = ( + error: unknown, + secrets: (string | null | undefined)[], +): unknown => { + if (error instanceof ExecError) { + return new ExecError(redactSecrets(error.message, secrets), { + command: redactSecrets(error.command, secrets), + stdout: error.stdout ? redactSecrets(error.stdout, secrets) : error.stdout, + stderr: error.stderr ? redactSecrets(error.stderr, secrets) : error.stderr, + exitCode: error.exitCode, + originalError: undefined, + serverId: error.serverId, + }); + } + if (error instanceof Error) { + error.message = redactSecrets(error.message, secrets); + } + return error; +}; + export const createRegistry = async ( input: z.infer, organizationId: string,