Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/dokploy/__test__/process/redact-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 13 additions & 5 deletions packages/server/src/utils/process/ExecError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redactErrorSecrets, redactSecrets } from "./redactSecrets";

export interface ExecErrorDetails {
command: string;
stdout?: string;
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions packages/server/src/utils/process/redactSecrets.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends Error>(error: T): T => {
const candidate = error as T & { cmd?: string };
candidate.message = redactSecrets(candidate.message);
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact exec error output properties

When a local execAsync command fails after writing a secret to stdout or stderr, the promisified child_process.exec error passed as originalError carries enumerable stdout and stderr properties. ExecError now redacts its own top-level output fields, but console.log(new ExecError(...)) will still print originalError.stdout/stderr, so a failed command that emits a PEM or base64 key before exiting can still leak the same secret this change is trying to remove.

Useful? React with 👍 / 👎.

if (candidate.stack) {
candidate.stack = redactSecrets(candidate.stack);
}
if (candidate.cmd) {
candidate.cmd = redactSecrets(candidate.cmd);
}
return candidate;
};