From c44845b53ef730fd1b188430e2c8e61cb7487862 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:58:02 -0400 Subject: [PATCH 1/4] fix(destination): test saved destinations with the stored secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - destination.testConnection принимает необязательный destinationId. Если форма прислала заглушку __DOKPLOY_REDACTED_SECRET__, роутер берёт сохранённый секрет этого destination после той же проверки organizationId, что в one и update. В облаке доступ к серверу проверяется до загрузки секрета. - Заглушка без destinationId отклоняется ошибкой BAD_REQUEST с понятным текстом до запуска rclone. - Форма редактирования destination передаёт destinationId при нажатии Test. Зачем: - API отдаёт форме секрет в виде заглушки, форма отправляла её обратно, и rclone подписывал запрос заглушкой. Поэтому Test для сохранённого S3 destination всегда падал с 403 SignatureDoesNotMatch. Риски: - Секрет, введённый в форме заново, используется как раньше; сохранённый подставляется только вместо заглушки. Значимых рисков не выявлено. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/destination __test__/backup __test__/backups __test__/security - 22 файла, 182 теста прошли; 4 из 6 новых тестов падают на старом роутере. bun run typecheck - 4 из 4 workspace без ошибок. bun run format-and-lint - exit 0, только прежние предупреждения в чужих файлах. CI=true bun run test - 2064 прошли, 8 упали только в *.real.test.ts (нет локального Docker daemon, таймауты реальной сборки). - Ограничения: проверки запускались на дереве со всеми исправлениями этой серии; кнопка Test не нажималась в браузере против реального S3; ничего не выкатывалось. What: - destination.testConnection accepts an optional destinationId. When the form sends the __DOKPLOY_REDACTED_SECRET__ placeholder, the router uses the stored secret of that destination after the same organizationId check as one and update. In cloud mode server access is checked before the secret is loaded. - The placeholder without destinationId is rejected with a clear BAD_REQUEST before rclone runs. - The destination edit form sends destinationId when Test is pressed. Why: - The API returns the secret to the form as a placeholder, the form sent it back, and rclone signed the request with the placeholder. Test on a saved S3 destination therefore always failed with 403 SignatureDoesNotMatch. Risks: - A secret typed again in the form is used as before; the stored one only replaces the placeholder. No significant risks identified. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/destination __test__/backup __test__/backups __test__/security - 22 files, 182 tests passed; 4 of the 6 new tests fail on the old router. bun run typecheck - 4 of 4 workspaces clean. bun run format-and-lint - exit 0, only existing warnings in unrelated files. CI=true bun run test - 2064 passed, 8 failed only in *.real.test.ts (no local Docker daemon, real build timeouts). - Limitations: checks ran on the tree with every fix of this series; the Test button was not pressed in a browser against a real S3 endpoint; nothing was deployed. Co-Authored-By: Claude Opus 5.5 --- ...destination-router-assigned-server.test.ts | 116 +++++++++++++++++- .../destination/handle-destinations.tsx | 1 + .../dokploy/server/api/routers/destination.ts | 25 +++- packages/server/src/db/schema/destination.ts | 4 + 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/destination/destination-router-assigned-server.test.ts b/apps/dokploy/__test__/destination/destination-router-assigned-server.test.ts index f354dfdeac9..b5bd4a0f41e 100644 --- a/apps/dokploy/__test__/destination/destination-router-assigned-server.test.ts +++ b/apps/dokploy/__test__/destination/destination-router-assigned-server.test.ts @@ -10,13 +10,16 @@ const mocks = vi.hoisted(() => ({ execAsyncRemote: vi.fn(), findDestinationById: vi.fn(), getAccessibleServerIds: vi.fn(), + isCloud: true, removeDestinationById: vi.fn(), assertDestinationEndpointAllowed: vi.fn(), updateDestinationById: vi.fn(), })); vi.mock("@dokploy/server", () => ({ - IS_CLOUD: true, + get IS_CLOUD() { + return mocks.isCloud; + }, createDestination: mocks.createDestination, execAsync: mocks.execAsync, execAsyncRemote: mocks.execAsyncRemote, @@ -83,6 +86,7 @@ const createCaller = () => describe("destination router assigned-server boundary", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.isCloud = true; mocks.checkPermission.mockResolvedValue(undefined); mocks.destinationFindMany.mockResolvedValue([]); mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); @@ -203,4 +207,114 @@ describe("destination router assigned-server boundary", () => { expect.stringContaining("rclone ls"), ); }); + + it("tests a saved destination with the stored secret when the edit form sends the placeholder", async () => { + mocks.isCloud = false; + + await expect( + createCaller().testConnection({ + ...destinationInput, + serverId: undefined, + secretAccessKey: REDACTED_SECRET_VALUE, + destinationId: "destination-1", + }), + ).resolves.toBeUndefined(); + + expect(mocks.findDestinationById).toHaveBeenCalledWith("destination-1"); + const command = mocks.execAsync.mock.calls[0]?.[0] as string; + expect(command).toContain( + "RCLONE_CONFIG_DOKPLOYS3_SECRET_ACCESS_KEY=stored-secret", + ); + expect(command).not.toContain(REDACTED_SECRET_VALUE); + }); + + it("uses a newly entered secret instead of the saved one", async () => { + mocks.isCloud = false; + + await expect( + createCaller().testConnection({ + ...destinationInput, + serverId: undefined, + secretAccessKey: "new-secret", + destinationId: "destination-1", + }), + ).resolves.toBeUndefined(); + + expect(mocks.findDestinationById).not.toHaveBeenCalled(); + expect(mocks.execAsync).toHaveBeenCalledWith( + expect.stringContaining( + "RCLONE_CONFIG_DOKPLOYS3_SECRET_ACCESS_KEY=new-secret", + ), + ); + }); + + it("rejects the placeholder secret without a destinationId before running rclone", async () => { + mocks.isCloud = false; + + await expect( + createCaller().testConnection({ + ...destinationInput, + serverId: undefined, + secretAccessKey: REDACTED_SECRET_VALUE, + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: expect.stringContaining("destinationId"), + }); + + expect(mocks.findDestinationById).not.toHaveBeenCalled(); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not use the stored secret of another organization's destination", async () => { + mocks.isCloud = false; + mocks.findDestinationById.mockResolvedValue({ + destinationId: "destination-2", + secretAccessKey: "other-org-secret", + organizationId: "org-2", + }); + + await expect( + createCaller().testConnection({ + ...destinationInput, + serverId: undefined, + secretAccessKey: REDACTED_SECRET_VALUE, + destinationId: "destination-2", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("checks cloud server access before loading the stored secret", async () => { + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"])); + + await expect( + createCaller().testConnection({ + ...destinationInput, + secretAccessKey: REDACTED_SECRET_VALUE, + destinationId: "destination-1", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mocks.findDestinationById).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("tests a saved destination on an accessible cloud server with the stored secret", async () => { + await expect( + createCaller().testConnection({ + ...destinationInput, + secretAccessKey: REDACTED_SECRET_VALUE, + destinationId: "destination-1", + }), + ).resolves.toBeUndefined(); + + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-1", + expect.stringContaining( + "RCLONE_CONFIG_DOKPLOYS3_SECRET_ACCESS_KEY=stored-secret", + ), + ); + }); }); diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index d1542dc8038..3823b34e44d 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -199,6 +199,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; await testConnection({ + destinationId, provider, accessKey, bucket, diff --git a/apps/dokploy/server/api/routers/destination.ts b/apps/dokploy/server/api/routers/destination.ts index 488ba0225e6..7c7a3221027 100644 --- a/apps/dokploy/server/api/routers/destination.ts +++ b/apps/dokploy/server/api/routers/destination.ts @@ -27,6 +27,7 @@ import { apiCreateDestination, apiFindOneDestination, apiRemoveDestination, + apiTestDestinationConnection, apiUpdateDestination, destinations, } from "@/server/db/schema"; @@ -86,7 +87,7 @@ export const destinationRouter = createTRPCRouter({ } }), testConnection: withPermission("destination", "create") - .input(apiCreateDestination) + .input(apiTestDestinationConnection) .mutation(async ({ input, ctx }) => { if (IS_CLOUD && !input.serverId) { throw new TRPCError({ @@ -98,8 +99,28 @@ export const destinationRouter = createTRPCRouter({ await assertDestinationServerAccess(ctx, input.serverId); } + const { destinationId, ...connectionInput } = input; + if (isRedactedSecretValue(connectionInput.secretAccessKey)) { + if (!destinationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The secret access key is hidden. Enter it again or pass destinationId to test the saved destination.", + }); + } + const destination = await findDestinationById(destinationId); + if (destination.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this destination", + }); + } + connectionInput.secretAccessKey = destination.secretAccessKey; + } + try { - const destinationInput = await normalizeDestinationEndpointInput(input); + const destinationInput = + await normalizeDestinationEndpointInput(connectionInput); const rcloneCommand = buildRcloneS3Command("ls", destinationInput, [ "--retries", "1", diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index 11d9f6997c3..beb26b6029a 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -77,6 +77,10 @@ export const apiCreateDestination = createSchema serverId: z.string().optional(), }); +export const apiTestDestinationConnection = apiCreateDestination.extend({ + destinationId: z.string().min(1).optional(), +}); + export const apiFindOneDestination = z.object({ destinationId: z.string().min(1), }); From 98c1fe22261277e5b4391f361b112123fa767a06 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:58:27 -0400 Subject: [PATCH 2/4] fix(backup): keep backup file listing under the postgres argument limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - getAccessibleBackupListingPrefixes загружает из связей application и compose запроса volumeBackups только appName. - Тест собирает настоящий SQL обоих запросов listBackupFiles через drizzle.mock с реальной схемой и проверяет, что ни один json_build_array не получает больше 100 аргументов. Зачем: - В таблице application 101 колонка. Drizzle упаковывает все колонки вложенной связи в один json_build_array, и Postgres отклонял запрос с 54023 "cannot pass more than 100 arguments to a function". Из-за этого backup.listBackupFiles всегда отвечал 400 и восстановление из бэкапа нельзя было начать. Риски: - Запрос backups (связи compose, postgres и другие) не менялся: сейчас максимум 49 аргументов, и тест упадёт, если он перейдёт порог. Тот же класс ошибки остаётся в других запросах с application: true (findDeploymentById, проверка восстановления volume backup) - они вне этой правки. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/backup/restore-security.test.ts - 40 тестов прошли; на старом роутере новый тест падает с "expected 101 to be less than or equal to 100". Общие прогоны те же, что в предыдущем коммите серии: 182 теста в затронутых областях прошли, bun run typecheck 4/4 без ошибок, bun run format-and-lint exit 0. - Ограничения: проверки запускались на дереве со всеми исправлениями этой серии; запрос не выполнялся против живой базы; ничего не выкатывалось. What: - getAccessibleBackupListingPrefixes loads only appName from the application and compose relations of the volumeBackups query. - A test compiles the real SQL of both listBackupFiles queries with drizzle.mock and the real schema and checks that no json_build_array call gets more than 100 arguments. Why: - The application table has 101 columns. Drizzle packs every column of a nested relation into one json_build_array call, and Postgres rejected the query with 54023 "cannot pass more than 100 arguments to a function". backup.listBackupFiles therefore always answered 400 and a restore could not be started. Risks: - The backups query (compose, postgres and other relations) is unchanged: it peaks at 49 arguments now, and the test fails if it crosses the limit. The same error class remains in other application: true queries (findDeploymentById, the volume backup restore check), which are outside this fix. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/backup/restore-security.test.ts - 40 tests passed; on the old router the new test fails with "expected 101 to be less than or equal to 100". Shared runs are the same as in the previous commit of the series: 182 tests passed in the touched areas, bun run typecheck 4/4 clean, bun run format-and-lint exit 0. - Limitations: checks ran on the tree with every fix of this series; the query was not run against a live database; nothing was deployed. Co-Authored-By: Claude Opus 5.5 --- .../__test__/backup/restore-security.test.ts | 92 +++++++++++++++++++ apps/dokploy/server/api/routers/backup.ts | 7 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/__test__/backup/restore-security.test.ts b/apps/dokploy/__test__/backup/restore-security.test.ts index 131e962e277..3fca4240a11 100644 --- a/apps/dokploy/__test__/backup/restore-security.test.ts +++ b/apps/dokploy/__test__/backup/restore-security.test.ts @@ -1,4 +1,6 @@ +import * as schema from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; +import { drizzle } from "drizzle-orm/postgres-js"; import { parse } from "shell-quote"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -324,6 +326,40 @@ const emit = (log: string) => emittedLogs.push(log); const parseShellArgs = (command: string) => parse(command).filter((part): part is string => typeof part === "string"); +const POSTGRES_MAX_FUNCTION_ARGS = 100; + +const maxJsonBuildArrayArgs = (sqlText: string) => { + let max = 0; + for (const match of sqlText.matchAll(/json_build_array\(/g)) { + let depth = 1; + let args = 1; + let inIdentifier = false; + for ( + let index = match.index + match[0].length; + index < sqlText.length && depth > 0; + index++ + ) { + const char = sqlText[index]; + if (char === '"') { + inIdentifier = !inIdentifier; + continue; + } + if (inIdentifier) { + continue; + } + if (char === "(") { + depth++; + } else if (char === ")") { + depth--; + } else if (char === "," && depth === 1) { + args++; + } + } + max = Math.max(max, args); + } + return max; +}; + describe("backup destination ownership boundary", () => { beforeEach(() => { vi.clearAllMocks(); @@ -784,6 +820,62 @@ describe("backup restore route boundary", () => { expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); }); + it("keeps backup file listing queries within the Postgres function argument limit", async () => { + const relationalDb = drizzle.mock({ schema }); + const compiledQueries: string[] = []; + mocks.findRestoreBackups.mockImplementation(async (config) => { + compiledQueries.push( + relationalDb.query.backups.findMany(config).toSQL().sql, + ); + return [ + { + appName: "backup-one", + backupType: "database", + databaseType: "postgres", + destinationId: "destination-1", + postgresId: "postgres-1", + prefix: "daily", + postgres: { appName: "app-one" }, + }, + ]; + }); + mocks.findVolumeBackupSchedules.mockImplementation(async (config) => { + compiledQueries.push( + relationalDb.query.volumeBackups.findMany(config).toSQL().sql, + ); + return [ + { + appName: "volume-backup-one", + applicationId: "application-1", + prefix: "volumes", + application: { appName: "web-app" }, + }, + ]; + }); + mocks.execAsync.mockResolvedValue({ stdout: "[]" }); + + await expect( + createCaller().listBackupFiles({ + destinationId: "destination-1", + search: "", + }), + ).resolves.toEqual([]); + + expect(compiledQueries).toHaveLength(2); + for (const sqlText of compiledQueries) { + expect(sqlText).toContain("json_build_array("); + expect(maxJsonBuildArrayArgs(sqlText)).toBeLessThanOrEqual( + POSTGRES_MAX_FUNCTION_ARGS, + ); + } + const listedArgs = mocks.execAsync.mock.calls.map(([command]) => + parseShellArgs((command as string).replace(/\s+2>\/dev\/null$/, "")), + ); + expect(listedArgs).toHaveLength(2); + expect(listedArgs[0]).toContain(":s3:dokploy-backups/app-one/daily/"); + expect(listedArgs[1]).toContain(":s3:dokploy-backups/web-app/volumes/"); + }); + it("requires backup management permission before remote backup file listing", async () => { mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"])); mocks.checkPermission.mockImplementation(async (_ctx, permissions) => { diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 7b035928699..54a1e753f00 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -518,11 +518,14 @@ const getAccessibleBackupListingPrefixes = async ( } } + // The application table has more than 100 columns, and Drizzle packs every + // selected column of a nested relation into one json_build_array() call, + // which Postgres rejects (54023). Only appName is needed for the prefix. const volumeBackupSchedules = await db.query.volumeBackups.findMany({ where: eq(volumeBackups.destinationId, destinationId), with: { - application: true, - compose: true, + application: { columns: { appName: true } }, + compose: { columns: { appName: true } }, }, }); From 549e1962bce2ed1effd33d4dd6d4eea761b88164 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:58:48 -0400 Subject: [PATCH 3/4] fix(backup): log masked failure output and dump the database once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Скрипт getBackupCommand делает дамп и выгрузку один раз: удалён второй блок "Starting upload to S3...", который заново запускал дамп и rclone rcat. - При ошибке скрипт пишет в лог "Backup failed", удаляет недокачанный файл через уже передаваемый rcloneDeleteCommand (как в upstream) и печатает UPLOAD_OUTPUT в stderr, а не в лог. - Новый runBackupCommand в backups/utils запускает скрипт локально или на удалённом сервере, при ошибке пропускает stderr через redactSensitiveText и дописывает его в logPath деплоя (локально через appendFile, удалённо через printf с экранированием). Шесть раннеров (postgres, mysql, mariadb, mongo, libsql, compose) вызывают его вместо своей копии if/else. Зачем: - Причина ошибки pg_dump или rclone сохранялась в UPLOAD_OUTPUT и терялась: в логе бэкапа было только "Check server logs for details", в логах сервера деталей тоже не было. Дамп и выгрузка шли дважды, удваивая нагрузку и время. Повторный блок и потеря очистки появились при слиянии upstream f633d4b0a. Риски: - Удалённый лог дописывается вторым SSH-вызовом; если он не удался, ошибка пишется в лог сервера, а исходная ошибка бэкапа пробрасывается без изменений. Маскировка покрывает только шаблоны redactSensitiveText. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/backup/backup-failure-output.test.ts __test__/backup/rclone-command-boundary.test.ts - все прошли. Новый тест запускает настоящий скрипт в bash с поддельными docker и rclone; на старом utils.ts он показывает второй docker exec и rclone rcat и отсутствие deletefile. Общие прогоны серии: 182 теста в затронутых областях прошли, bun run typecheck 4/4 без ошибок, bun run format-and-lint exit 0. - Ограничения: проверки запускались на дереве со всеми исправлениями этой серии; настоящий бэкап в S3 не запускался; ничего не выкатывалось. What: - The getBackupCommand script dumps and uploads once: the second "Starting upload to S3..." block that re-ran the dump and rclone rcat is removed. - On failure the script logs "Backup failed", removes the partial upload with the already passed rcloneDeleteCommand (as upstream does) and prints UPLOAD_OUTPUT to stderr instead of the log. - A new runBackupCommand in backups/utils runs the script locally or on a remote server and, on failure, passes stderr through redactSensitiveText and appends it to the deployment logPath (appendFile locally, quoted printf remotely). The six runners (postgres, mysql, mariadb, mongo, libsql, compose) call it instead of their own if/else copy. Why: - The pg_dump or rclone failure reason was captured in UPLOAD_OUTPUT and lost: the backup log only said "Check server logs for details", and the server logs had no details either. The dump and upload ran twice, doubling load and time. The duplicate block and the lost cleanup came from merging upstream f633d4b0a. Risks: - The remote log is appended with a second SSH call; if it fails, the error goes to the server log and the original backup error is rethrown unchanged. Masking covers only the redactSensitiveText patterns. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/backup/backup-failure-output.test.ts __test__/backup/rclone-command-boundary.test.ts - all passed. The new test runs the real script in bash with fake docker and rclone; against the old utils.ts it shows a second docker exec and rclone rcat and no deletefile. Shared runs of the series: 182 tests passed in the touched areas, bun run typecheck 4/4 clean, bun run format-and-lint exit 0. - Limitations: checks ran on the tree with every fix of this series; no real backup to S3 was run; nothing was deployed. Co-Authored-By: Claude Opus 5.5 --- .../backup/backup-failure-output.test.ts | 232 ++++++++++++++++++ .../backup/rclone-command-boundary.test.ts | 10 +- packages/server/src/utils/backups/compose.ts | 10 +- packages/server/src/utils/backups/libsql.ts | 10 +- packages/server/src/utils/backups/mariadb.ts | 10 +- packages/server/src/utils/backups/mongo.ts | 10 +- packages/server/src/utils/backups/mysql.ts | 10 +- packages/server/src/utils/backups/postgres.ts | 14 +- packages/server/src/utils/backups/utils.ts | 71 +++++- 9 files changed, 311 insertions(+), 66 deletions(-) create mode 100644 apps/dokploy/__test__/backup/backup-failure-output.test.ts diff --git a/apps/dokploy/__test__/backup/backup-failure-output.test.ts b/apps/dokploy/__test__/backup/backup-failure-output.test.ts new file mode 100644 index 00000000000..d7e7e0392b3 --- /dev/null +++ b/apps/dokploy/__test__/backup/backup-failure-output.test.ts @@ -0,0 +1,232 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; +import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; +import { parse } from "shell-quote"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/lib/logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +const { buildRcloneS3Command, getBackupCommand, runBackupCommand } = + await import("@dokploy/server/utils/backups/utils"); + +const destination = { + accessKey: "access-key", + additionalFlags: [], + bucket: "bucket", + endpoint: "https://s3.example.test", + provider: "AWS", + region: "us-east-1", + secretAccessKey: "s3-secret", +}; + +const postgresBackup = { + backupId: "backup-1", + backupType: "database", + database: "appdb", + databaseType: "postgres", + destinationId: "destination-1", + postgresId: "postgres-1", + postgres: { appName: "postgres-app", databaseUser: "postgres" }, + prefix: "daily", +} as never; + +const rcloneFailureOutput = [ + "Failed to rcat: SignatureDoesNotMatch: status code: 403", + "RCLONE_CONFIG_DOKPLOYS3_SECRET_ACCESS_KEY=leaked-secret", +].join("\n"); + +let tempDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + tempDir = mkdtempSync(join(tmpdir(), "dokploy-backup-failure-")); +}); + +afterEach(() => { + rmSync(tempDir, { force: true, recursive: true }); +}); + +const writeExecutable = (path: string, content: string) => { + writeFileSync(path, content); + chmodSync(path, 0o755); +}; + +const runBackupScript = (rcatExitCode: number) => { + const binDir = join(tempDir, "bin"); + const callLog = join(tempDir, "calls.log"); + const logPath = join(tempDir, "backup.log"); + mkdirSync(binDir); + writeFileSync(callLog, ""); + writeFileSync(logPath, ""); + writeExecutable( + join(binDir, "docker"), + `#!/bin/sh +echo "docker $1" >> "$CALL_LOG" +case "$1" in + ps) echo "container-1" ;; + exec) printf 'dump-bytes' ;; +esac +`, + ); + writeExecutable( + join(binDir, "rclone"), + `#!/bin/sh +echo "rclone $1" >> "$CALL_LOG" +if [ "$1" = "rcat" ]; then + cat > /dev/null + if [ ${rcatExitCode} -ne 0 ]; then +${rcloneFailureOutput + .split("\n") + .map((line) => `\t\techo '${line}' >&2`) + .join("\n")} + exit ${rcatExitCode} + fi +fi +`, + ); + + const target = "dokploys3:bucket/postgres-app/daily/backup.sql.gz"; + const script = getBackupCommand( + postgresBackup, + buildRcloneS3Command("rcat", destination, [target]), + buildRcloneS3Command("deletefile", destination, [target]), + logPath, + ); + const result = spawnSync("/bin/bash", ["-c", script], { + encoding: "utf8", + env: { + CALL_LOG: callLog, + NODE_ENV: "test", + PATH: `${binDir}:/usr/bin:/bin`, + }, + }); + + return { + calls: readFileSync(callLog, "utf8").trim().split("\n"), + log: readFileSync(logPath, "utf8"), + result, + }; +}; + +describe("backup script", () => { + it("dumps the database and uploads it once", () => { + const { calls, log, result } = runBackupScript(0); + + expect(result.status).toBe(0); + expect(calls).toEqual(["docker ps", "docker exec", "rclone rcat"]); + expect(log).toContain("✅ Backup uploaded to S3 successfully"); + expect(log).toContain("Backup done ✅"); + }); + + it("prints failure output to stderr instead of the log and removes the partial upload", () => { + const { calls, log, result } = runBackupScript(1); + + expect(result.status).toBe(1); + expect(calls).toEqual([ + "docker ps", + "docker exec", + "rclone rcat", + "rclone deletefile", + ]); + expect(log).toContain("❌ Error: Backup failed"); + expect(log).not.toContain("SignatureDoesNotMatch"); + expect(log).not.toContain("leaked-secret"); + expect(result.stderr).toContain("SignatureDoesNotMatch"); + }); +}); + +describe("runBackupCommand", () => { + const failedBackup = (serverId?: string) => + new ExecError("Command execution failed", { + command: "backup script", + stderr: `${rcloneFailureOutput}\n`, + exitCode: 1, + serverId, + }); + + it("appends the masked failure output to a local deployment log", async () => { + const logPath = join(tempDir, "backup.log"); + writeFileSync(logPath, "[date] ❌ Error: Backup failed\n"); + mocks.execAsync.mockRejectedValue(failedBackup()); + + await expect( + runBackupCommand("backup script", logPath, null), + ).rejects.toBeInstanceOf(ExecError); + + expect(mocks.execAsync).toHaveBeenCalledWith("backup script", { + shell: "/bin/bash", + }); + const log = readFileSync(logPath, "utf8"); + expect(log).toContain( + "Error: Failed to rcat: SignatureDoesNotMatch: status code: 403", + ); + expect(log).toContain(REDACTED_SECRET_VALUE); + expect(log).not.toContain("leaked-secret"); + }); + + it("appends the masked failure output to a remote deployment log", async () => { + mocks.execAsyncRemote + .mockRejectedValueOnce(failedBackup("server-1")) + .mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await expect( + runBackupCommand( + "backup script", + "/etc/dokploy/logs/app.log", + "server-1", + ), + ).rejects.toBeInstanceOf(ExecError); + + expect(mocks.execAsyncRemote).toHaveBeenCalledTimes(2); + const [serverId, appendCommand] = mocks.execAsyncRemote.mock.calls[1] as [ + string, + string, + ]; + expect(serverId).toBe("server-1"); + const args = parse(appendCommand); + expect(args.slice(0, 2)).toEqual(["printf", "%s\\n"]); + expect(args[2]).toContain( + "Error: Failed to rcat: SignatureDoesNotMatch: status code: 403", + ); + expect(args[2]).toContain(REDACTED_SECRET_VALUE); + expect(appendCommand).not.toContain("leaked-secret"); + expect(args.slice(3)).toEqual([{ op: ">>" }, "/etc/dokploy/logs/app.log"]); + }); + + it("keeps the original error when there is no command output", async () => { + const logPath = join(tempDir, "backup.log"); + writeFileSync(logPath, ""); + mocks.execAsync.mockRejectedValue(new Error("spawn failed")); + + await expect(runBackupCommand("backup script", logPath)).rejects.toThrow( + "spawn failed", + ); + + expect(readFileSync(logPath, "utf8")).toBe(""); + }); +}); diff --git a/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts b/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts index d17a0c87670..dd2b85f6999 100644 --- a/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts +++ b/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts @@ -317,13 +317,11 @@ describe("destination rclone command boundary", () => { const command = mocks.execAsyncRemote.mock.calls[0]?.[1] as string; expect(command).toContain("UPLOAD_OUTPUT="); - expect(command).toContain( - "Error: Backup command failed. Check server logs for details.", - ); - expect(command).toContain( - "Error: Upload command failed. Check server logs for details.", - ); + expect(command).toContain(`printf '%s\\n' "$UPLOAD_OUTPUT" >&2;`); expect(command).not.toContain('echo "Error: $UPLOAD_OUTPUT"'); + expect(command).not.toMatch(/\$UPLOAD_OUTPUT"?\s*>>/); + expect(command.match(/pg_dump/g)).toHaveLength(1); + expect(command.match(/\brcat\b/g)).toHaveLength(1); }); it("wraps retention rclone delete commands after xargs", async () => { diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 0fc2468bb4f..86ba82ca8dd 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -8,7 +8,6 @@ import { findDestinationById } from "@dokploy/server/services/destination"; import { findEnvironmentById } from "@dokploy/server/services/environment"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runComposeBackup = async ( @@ -57,13 +57,7 @@ export const runComposeBackup = async ( rcloneDeleteCommand, deployment.logPath, ); - if (compose.serverId) { - await execAsyncRemote(compose.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand(backupCommand, deployment.logPath, compose.serverId); await sendDatabaseBackupNotifications({ applicationName: name, diff --git a/packages/server/src/utils/backups/libsql.ts b/packages/server/src/utils/backups/libsql.ts index 45fb14b2fa9..5a32dc94065 100644 --- a/packages/server/src/utils/backups/libsql.ts +++ b/packages/server/src/utils/backups/libsql.ts @@ -8,7 +8,6 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import type { Libsql } from "@dokploy/server/services/libsql"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runLibsqlBackup = async ( @@ -56,13 +56,7 @@ export const runLibsqlBackup = async ( rcloneDeleteCommand, deployment.logPath, ); - if (libsql.serverId) { - await execAsyncRemote(libsql.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand(backupCommand, deployment.logPath, libsql.serverId); await sendDatabaseBackupNotifications({ applicationName: name, diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 73a051a6b5e..35a56807fe1 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -8,7 +8,6 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import type { Mariadb } from "@dokploy/server/services/mariadb"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runMariadbBackup = async ( @@ -55,13 +55,7 @@ export const runMariadbBackup = async ( rcloneDeleteCommand, deployment.logPath, ); - if (mariadb.serverId) { - await execAsyncRemote(mariadb.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand(backupCommand, deployment.logPath, mariadb.serverId); await sendDatabaseBackupNotifications({ applicationName: name, diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 2ea1bf71c9c..529fea64e01 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -8,7 +8,6 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import type { Mongo } from "@dokploy/server/services/mongo"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { @@ -53,13 +53,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { deployment.logPath, ); - if (mongo.serverId) { - await execAsyncRemote(mongo.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand(backupCommand, deployment.logPath, mongo.serverId); await sendDatabaseBackupNotifications({ applicationName: name, diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 98ae21336a0..62987b304dd 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -8,7 +8,6 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import type { MySql } from "@dokploy/server/services/mysql"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { @@ -54,13 +54,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { deployment.logPath, ); - if (mysql.serverId) { - await execAsyncRemote(mysql.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand(backupCommand, deployment.logPath, mysql.serverId); await sendDatabaseBackupNotifications({ applicationName: name, projectName: project.name, diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 617c604cbf6..165fc4da2c5 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -8,7 +8,6 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import type { Postgres } from "@dokploy/server/services/postgres"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; import { assertRcloneS3DestinationAllowed, buildRcloneS3Command, @@ -16,6 +15,7 @@ import { getBackupTimestamp, getRcloneS3Destination, normalizeS3Path, + runBackupCommand, } from "./utils"; export const runPostgresBackup = async ( @@ -56,13 +56,11 @@ export const runPostgresBackup = async ( rcloneDeleteCommand, deployment.logPath, ); - if (postgres.serverId) { - await execAsyncRemote(postgres.serverId, backupCommand); - } else { - await execAsync(backupCommand, { - shell: "/bin/bash", - }); - } + await runBackupCommand( + backupCommand, + deployment.logPath, + postgres.serverId, + ); await sendDatabaseBackupNotifications({ applicationName: name, diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 896608eaaba..8f5deeb023e 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -1,3 +1,4 @@ +import { appendFile } from "node:fs/promises"; import { assertRcloneAdditionalFlagsAllowed } from "@dokploy/server/db/validations/destination"; import { logger } from "@dokploy/server/lib/logger"; import type { BackupSchedule } from "@dokploy/server/services/backup"; @@ -6,6 +7,11 @@ import { assertDestinationEndpointAllowed, normalizeDestinationEndpointUrl, } from "@dokploy/server/utils/destination/endpoint"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; import { normalizeRestoreDatabaseName, normalizeRestoreServiceName, @@ -437,7 +443,7 @@ export const generateBackupCommand = (backup: BackupSchedule) => { export const getBackupCommand = ( backup: BackupSchedule, rcloneCommand: string, - _rcloneDeleteCommand: string, + rcloneDeleteCommand: string, logPath: string, ) => { if (!isBackupScheduleTargetBound(backup)) { @@ -477,17 +483,8 @@ export const getBackupCommand = ( UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || { echo "[$(date)] ❌ Error: Backup failed" >> ${logPath}; - echo "Error: Backup command failed. Check server logs for details." >> ${logPath}; - exit 1; - } - - echo "[$(date)] ✅ backup completed successfully" >> ${logPath}; - echo "[$(date)] Starting upload to S3..." >> ${logPath}; - - # Run the upload command and capture the exit status - UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || { - echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath}; - echo "Error: Upload command failed. Check server logs for details." >> ${logPath}; + ${rcloneDeleteCommand} >/dev/null 2>&1 || true; + printf '%s\\n' "$UPLOAD_OUTPUT" >&2; exit 1; }; @@ -495,3 +492,53 @@ export const getBackupCommand = ( echo "Backup done ✅" >> ${logPath}; `; }; + +const appendBackupErrorOutput = async ( + error: unknown, + logPath: string, + serverId?: string | null, +) => { + const output = + error instanceof ExecError ? redactSensitiveText(error.stderr)?.trim() : ""; + if (!output) { + return; + } + + const logLine = `Error: ${output}`; + try { + if (serverId) { + await execAsyncRemote( + serverId, + `printf '%s\\n' ${quoteShellArgument(logLine)} >> ${quoteShellArgument(logPath)}`, + ); + } else { + await appendFile(logPath, `${logLine}\n`); + } + } catch (appendError) { + logger.error( + { logPath, error: redactSensitiveText(String(appendError)) }, + "Failed to append backup error output to the deployment log", + ); + } +}; + +// The backup script never writes command output to the log itself: it prints +// it to stderr, and it is masked here before it reaches the deployment log. +export const runBackupCommand = async ( + command: string, + logPath: string, + serverId?: string | null, +) => { + try { + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command, { + shell: "/bin/bash", + }); + } + } catch (error) { + await appendBackupErrorOutput(error, logPath, serverId); + throw error; + } +}; From e602d8633cab319c61a9c23c249c0333d35064dc Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:59:03 -0400 Subject: [PATCH 4/4] fix(redaction): keep the argument after negated password flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - redactSensitiveText больше не маскирует слово после флагов вида --no-password: правило для флагов CLI пропускает флаги --no-*, а правило PASSWORD не срабатывает на no-password. - Тест проверяет, что имя БД после --no-password остаётся видимым, а --password, --db-password=, -password, PASSWORD '...' и IDENTIFIED BY '...' по-прежнему маскируются. Зачем: - В логе команды бэкапа postgres имя базы после --no-password заменялось на заглушку, хотя это не секрет: флаг булев и значения не принимает. Риски: - Правка общей функции маскировки. Исключение узкое: только флаги, начинающиеся с no-, которые по соглашению не принимают значений. Прочие правила не менялись. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/security/secret-redaction.test.ts - 14 тестов прошли; на старом redaction.ts новый тест падает. Общие прогоны серии: 182 теста в затронутых областях прошли, bun run typecheck 4/4 без ошибок, bun run format-and-lint exit 0, CI=true bun run test - 2064 прошли, 8 упали только в *.real.test.ts (нет локального Docker daemon, таймауты реальной сборки). - Ограничения: проверки запускались на дереве со всеми исправлениями этой серии; ничего не выкатывалось. What: - redactSensitiveText no longer masks the word after flags such as --no-password: the CLI flag rule skips --no-* flags, and the PASSWORD rule does not match no-password. - A test checks that the database name after --no-password stays visible while --password, --db-password=, -password, PASSWORD '...' and IDENTIFIED BY '...' are still masked. Why: - The postgres backup command log replaced the database name after --no-password with the placeholder, although it is not a secret: the flag is boolean and takes no value. Risks: - This changes the shared masking function. The exception is narrow: only flags starting with no-, which by convention take no value. Other rules are unchanged. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/security/secret-redaction.test.ts - 14 tests passed; the new test fails on the old redaction.ts. Shared runs of the series: 182 tests passed in the touched areas, bun run typecheck 4/4 clean, bun run format-and-lint exit 0, CI=true bun run test - 2064 passed, 8 failed only in *.real.test.ts (no local Docker daemon, real build timeouts). - Limitations: checks ran on the tree with every fix of this series; nothing was deployed. Co-Authored-By: Claude Opus 5.5 --- .../security/secret-redaction.test.ts | 22 +++++++++++++++++++ .../server/src/utils/security/redaction.ts | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/__test__/security/secret-redaction.test.ts b/apps/dokploy/__test__/security/secret-redaction.test.ts index 012c19f7512..1cd816292d2 100644 --- a/apps/dokploy/__test__/security/secret-redaction.test.ts +++ b/apps/dokploy/__test__/security/secret-redaction.test.ts @@ -309,6 +309,28 @@ describe("shared secret redaction helpers", () => { } }); + it("keeps the argument after a negated password flag while masking real passwords", () => { + expect( + redactSensitiveText( + "pg_dump -Fc -h localhost -U postgres --no-password appdb | gzip", + ), + ).toContain("--no-password appdb |"); + + const messages = [ + "psql --password hunter2 -d appdb", + "tool --db-password=hunter2", + "cli -password hunter2", + "ALTER USER app WITH PASSWORD 'hunter2'", + "CREATE USER 'app'@'%' IDENTIFIED BY 'hunter2'", + ]; + for (const message of messages) { + const redacted = redactSensitiveText(message); + + expect(redacted).not.toContain("hunter2"); + expect(redacted).toContain(REDACTED_SECRET_VALUE); + } + }); + it("stores only redacted command output on ExecError", () => { const error = new ExecError( "Command failed: npm run build TOKEN=build-secret", diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 20bec645829..a55be3f9ef4 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -335,16 +335,18 @@ export function redactSensitiveText(value: string | null | undefined) { `$1${REDACTED_SECRET_VALUE}`, ); + // Negated flags such as pg_dump's `--no-password` take no value, so the word + // after them is a positional argument (the database name), not a secret. redacted = redacted.replace( new RegExp( - `(\\s--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))("[^"]*"|'[^']*'|[^\\s;&|]+)`, + `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))("[^"]*"|'[^']*'|[^\\s;&|]+)`, "gi", ), `$1${REDACTED_SECRET_VALUE}`, ); redacted = redacted.replace( - /(\b(?:PASSWORD|IDENTIFIED\s+BY)\s+)('[^']*'|"[^"]*"|[^\s;&|]+)/gi, + /(\b(?