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,