diff --git a/apps/dokploy/__test__/process/exec-error-secrets.test.ts b/apps/dokploy/__test__/process/exec-error-secrets.test.ts new file mode 100644 index 000000000..64058ecaf --- /dev/null +++ b/apps/dokploy/__test__/process/exec-error-secrets.test.ts @@ -0,0 +1,17 @@ +import { inspect } from "node:util"; +import { execAsync, ExecError } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it } from "vitest"; + +describe("execAsync failure", () => { + it("does not leak secrets embedded in the command", async () => { + const secret = "s3cr3t-value"; + const error = await execAsync(`printf %s '${secret}' >/dev/null; exit 3`) + .then(() => null) + .catch((e) => e); + + expect(error).toBeInstanceOf(ExecError); + expect(error.exitCode).toBe(3); + expect(error.message).not.toContain(secret); + expect(inspect(error)).not.toContain(secret); + }); +}); diff --git a/packages/server/src/utils/process/ExecError.ts b/packages/server/src/utils/process/ExecError.ts index 773968b5c..d76c17627 100644 --- a/packages/server/src/utils/process/ExecError.ts +++ b/packages/server/src/utils/process/ExecError.ts @@ -25,6 +25,11 @@ export class ExecError extends Error { this.originalError = details.originalError; this.serverId = details.serverId; + // Keep the command (may embed secrets) out of console/inspect output. + for (const key of ["command", "originalError"] as const) { + Object.defineProperty(this, key, { enumerable: false }); + } + // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ExecError); diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index 37b0b7f91..bc700d0e0 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -23,6 +23,10 @@ export { ExecError } from "./ExecError"; const execAsyncBase = util.promisify(exec); +// Node embeds the full command in error.message; it may carry secrets. +const withoutCommand = (message: string, command: string) => + message.split(command).join("[command omitted]"); + export const execAsync = async ( command: string, options?: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string }, @@ -42,13 +46,16 @@ export const execAsync = async ( // @ts-expect-error const stderr = error.stderr?.toString() || ""; - throw new ExecError(`Command execution failed: ${error.message}`, { - command, - stdout, - stderr, - exitCode, - originalError: error, - }); + throw new ExecError( + `Command execution failed: ${withoutCommand(error.message, command)}`, + { + command, + stdout, + stderr, + exitCode, + originalError: error, + }, + ); } throw error; } @@ -71,13 +78,16 @@ export const execAsyncStream = ( const childProcess = exec(command, options, (error) => { if (error) { reject( - new ExecError(`Command execution failed: ${error.message}`, { - command, - stdout: stdoutComplete, - stderr: stderrComplete, - exitCode: error.code, - originalError: error, - }), + new ExecError( + `Command execution failed: ${withoutCommand(error.message, command)}`, + { + command, + stdout: stdoutComplete, + stderr: stderrComplete, + exitCode: error.code, + originalError: error, + }, + ), ); return; }