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
This commit is contained in:
Souvik Kumar 2026-08-13 00:49:05 +05:30
parent 2bda2ccc04
commit 81afaf45e2
3 changed files with 145 additions and 10 deletions

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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<typeof apiCreateRegistry>,
organizationId: string,