mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
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:
parent
2bda2ccc04
commit
81afaf45e2
65
apps/dokploy/__test__/registry/redact-secrets.test.ts
Normal file
65
apps/dokploy/__test__/registry/redact-secrets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user