diff --git a/apps/dokploy/__test__/process/redact-secrets.test.ts b/apps/dokploy/__test__/process/redact-secrets.test.ts new file mode 100644 index 0000000000..6d3205f9cb --- /dev/null +++ b/apps/dokploy/__test__/process/redact-secrets.test.ts @@ -0,0 +1,38 @@ +import { redactSecrets } from "@dokploy/server/utils/process/redactSecrets"; +import { describe, expect, it } from "vitest"; + +// All key material below is synthetic: these base64 strings decode to the +// literal text "synthetic-test-not-a-real-...-key" and are not real keys. + +describe("redactSecrets", () => { + it("redacts a PEM private key block written to /tmp/id_rsa", () => { + const secret = "c3ludGhldGljLXRlc3Qtbm90LWEtcmVhbC1wcml2YXRlLWtleQ=="; + const command = + `echo "-----BEGIN OPENSSH PRIVATE KEY-----\n${secret}\n-----END OPENSSH PRIVATE KEY-----" > /tmp/id_rsa;` + + "chmod 600 /tmp/id_rsa;git clone --branch main --depth 1 git@example.com:org/repo /code"; + + const redacted = redactSecrets(command); + + expect(redacted).not.toContain(secret); + expect(redacted).toContain("[REDACTED PRIVATE KEY]"); + expect(redacted).toContain("chmod 600 /tmp/id_rsa"); + expect(redacted).toContain("git clone --branch main"); + }); + + it("redacts a base64 key piped to base64 -d", () => { + const secret = "c3ludGhldGljLXRlc3Qtbm90LWEtcmVhbC1jZXJ0LWtleQ=="; + const command = `echo "${secret}" | base64 -d > "/etc/dokploy/cert.key";`; + + const redacted = redactSecrets(command); + + expect(redacted).not.toContain(secret); + expect(redacted).toContain('echo "[REDACTED]" | base64 -d'); + }); + + it("leaves commands without secrets untouched", () => { + const command = + "git clone --branch main --depth 1 git@github.com:org/repo.git /tmp/code"; + + expect(redactSecrets(command)).toBe(command); + }); +}); diff --git a/packages/server/src/utils/process/ExecError.ts b/packages/server/src/utils/process/ExecError.ts index 773968b5cf..2335e4d038 100644 --- a/packages/server/src/utils/process/ExecError.ts +++ b/packages/server/src/utils/process/ExecError.ts @@ -1,3 +1,5 @@ +import { redactErrorSecrets, redactSecrets } from "./redactSecrets"; + export interface ExecErrorDetails { command: string; stdout?: string; @@ -16,13 +18,19 @@ export class ExecError extends Error { public readonly serverId?: string | null; constructor(message: string, details: ExecErrorDetails) { - super(message); + super(redactSecrets(message)); this.name = "ExecError"; - this.command = details.command; - this.stdout = details.stdout; - this.stderr = details.stderr; + this.command = redactSecrets(details.command); + this.stdout = details.stdout + ? redactSecrets(details.stdout) + : details.stdout; + this.stderr = details.stderr + ? redactSecrets(details.stderr) + : details.stderr; this.exitCode = details.exitCode; - this.originalError = details.originalError; + this.originalError = details.originalError + ? redactErrorSecrets(details.originalError) + : details.originalError; this.serverId = details.serverId; // Maintains proper stack trace for where our error was thrown (only available on V8) diff --git a/packages/server/src/utils/process/redactSecrets.ts b/packages/server/src/utils/process/redactSecrets.ts new file mode 100644 index 0000000000..f6609678e5 --- /dev/null +++ b/packages/server/src/utils/process/redactSecrets.ts @@ -0,0 +1,30 @@ +// Dokploy embeds some secrets directly into the shell commands it runs: the +// SSH key written to /tmp/id_rsa when cloning over SSH, and the base64 TLS key +// piped to `base64 -d` when provisioning certificates on a remote server. When +// such a command fails, its ExecError (command/stdout/stderr) is logged, which +// would otherwise persist the secret in plain text. These helpers strip that +// material before it can reach the logs. + +const PRIVATE_KEY_BLOCK = + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g; + +const BASE64_DECODE_PIPE = /echo "[A-Za-z0-9+/=]+"\s*\|\s*base64 -d/g; + +export const redactSecrets = (value: string): string => + value + .replace(PRIVATE_KEY_BLOCK, "[REDACTED PRIVATE KEY]") + .replace(BASE64_DECODE_PIPE, 'echo "[REDACTED]" | base64 -d'); + +// Node's child_process errors repeat the failed command on `message`, `stack` +// and `cmd`, so redact those too when wrapping an original error. +export const redactErrorSecrets = (error: T): T => { + const candidate = error as T & { cmd?: string }; + candidate.message = redactSecrets(candidate.message); + if (candidate.stack) { + candidate.stack = redactSecrets(candidate.stack); + } + if (candidate.cmd) { + candidate.cmd = redactSecrets(candidate.cmd); + } + return candidate; +};