From e3a8bd1b9028337cd9451de0216ebf5e64067523 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:30:15 -0400 Subject: [PATCH 1/5] feat(security): keep secret values out of API key responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Ответы на запросы по API-ключу проходят общий фильтр в базовой tRPC-прослойке. Поля с секретами (env, build args и secrets, пароли, токены, ключи, webhook URL, заголовки) заменяются плейсхолдером при любой роли владельца ключа, включая owner. В списках вида NAME=value (docker Env) и в картах остаются имена, значения скрываются. - Из остального текста (логи контейнеров и деплоев, compose-файлы, содержимое mount-файлов, команды, тексты ошибок, чанки подписок) вырезаются значения секретов, которые хранит панель (env сервисов, проектов и окружений, пароли БД и registry), и текст, похожий на учётные данные: NAME=value и key: value с секретным именем, пароли в URL, Bearer/Basic, известные форматы токенов, блоки приватных ключей. Ссылки на переменные (${VAR}, $VAR, ${{project.VAR}}) не трогаются. - API-ключ больше не может вызвать процедуры, которые отдают секрет или сырые байты файла: *.revealEnvironment, user.revealApiKey, docker.getConfig, docker.readContainerFile, dockerVolume.readVolumeFile, settings.readTraefikEnv. Раньше это зависело от того, задан ли супер-пароль. - API-ключ не может открыть WebSocket терминала и сырых потоков логов (контейнер, деплой); логи читаются через readLogs с фильтром. - Запись по API-ключу отклоняется, если во входных данных есть вычищенный текст: иначе плейсхолдер перезаписал бы настоящее значение. Поле-секрет, присланное целиком как плейсхолдер, по-прежнему значит «оставить сохранённое». - Логи, которые панель отправляет своему AI-провайдеру (ai.analyzeLogs), проходят тот же фильтр. - Документ OpenAPI отдаётся без фильтра: в нём имена полей, а не значения. Зачем: - По API-ключу с панелью работают скрипты и ИИ-агенты (MCP). Владелец хочет поручать им работу с сервисами так, чтобы значения env и другие секреты не попадали в контекст модели ни через поля ответа, ни через логи, файлы и ошибки. Риски: - Фильтр по шаблонам намеренно широкий: в логах и compose-файлах может быть скрыто несекретное значение (например "passed: 12" или "author: ..."). Для сессий в браузере ничего не меняется. - Секрет, которого нет в панели и который записан в тексте без узнаваемого имени или формата (например, значение под нейтральным именем прямо в compose-файле), фильтр не распознает. - Список известных значений кешируется на 30 секунд и сбрасывается после каждой мутации; значения короче 8 символов, из одних строчных букв или из одних цифр в него не входят, чтобы не портить обычный вывод. - Клиенты, которые по API-ключу читали файлы контейнеров и томов, docker inspect или сырые WebSocket-логи, получат отказ. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2827 passed, 1 failed (backup-failure-output, таймаут 5 с при параллельном запуске; отдельный запуск файла - 5 passed); `biome check` по изменённым файлам - 0 ошибок, 3 предупреждения useOptionalChain в строках, которые не менялись. - Ограничения: на живой панели не проверялось; `*.real.test.ts` не запускались (нужны swarm и сеть). What: - Responses to API key requests go through one filter in the base tRPC middleware. Secret-bearing fields (env, build args and secrets, passwords, tokens, keys, webhook URLs, headers) are replaced with the placeholder whatever role the key's owner has, owner included. NAME=value lists (docker Env) and maps keep their names and lose their values. - Every other string (container and deployment logs, compose files, mounted file contents, commands, error messages, subscription chunks) is scrubbed of the secret values the panel stores (service, project and environment env, database and registry passwords) and of text that looks like a credential: NAME=value and key: value with a secret-looking name, URL passwords, Bearer/Basic, well-known token shapes, private key blocks. Variable references (${VAR}, $VAR, ${{project.VAR}}) are left alone. - An API key can no longer call the procedures that hand back a secret or raw file bytes: *.revealEnvironment, user.revealApiKey, docker.getConfig, docker.readContainerFile, dockerVolume.readVolumeFile, settings.readTraefikEnv. This used to depend on a super password being set. - An API key cannot open the terminal WebSockets or the raw log streams (container, deployment); logs are read through readLogs, which is scrubbed. - An API key write is rejected when its input contains scrubbed text, which would otherwise overwrite the real value with the placeholder. A secret field sent back as the bare placeholder still means "keep what is stored". - Logs the panel sends to its own AI provider (ai.analyzeLogs) go through the same scrubber. - The OpenAPI document is returned unfiltered: it holds field names, not values. Why: - Scripts and AI agents (MCP) talk to the panel with an API key. The owner wants to hand them service work without env values and other secrets reaching the model's context through response fields, logs, files or errors. Risks: - The pattern scrubber is broad on purpose: a non-secret value in logs or compose files can be hidden (for example "passed: 12" or "author: ..."). Browser sessions are not affected. - A secret the panel does not store, written in text without a recognisable name or shape (for example a value under a neutral name inline in a compose file), is not recognised. - The known value list is cached for 30 seconds and dropped after every mutation; values shorter than 8 characters, all lowercase letters or all digits stay out of it so ordinary output is not mangled. - Clients that used an API key to read container and volume files, docker inspect or raw WebSocket logs are now refused. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2827 passed, 1 failed (backup-failure-output, 5 s timeout under parallel load; the file alone - 5 passed); `biome check` on the changed files - 0 errors, 3 useOptionalChain warnings on lines that were not changed. - Limitations: not exercised against a live panel; `*.real.test.ts` were not run (they need swarm and network). Co-Authored-By: Claude Opus 5.5 --- .../placement/deploy-target-boundary.test.ts | 41 +++ .../security/api-key-output-guard.test.ts | 282 ++++++++++++++ .../__test__/security/known-secrets.test.ts | 134 +++++++ .../__test__/security/secret-scrub.test.ts | 343 ++++++++++++++++++ .../security/super-session-guard.test.ts | 24 +- apps/dokploy/__test__/wss/api-key.test.ts | 21 ++ .../pages/api/application.env.upsert.ts | 2 + apps/dokploy/server/api/routers/ai.ts | 6 +- apps/dokploy/server/api/trpc.ts | 40 +- .../server/api/utils/api-key-output-guard.ts | 105 ++++++ apps/dokploy/server/wss/api-key.ts | 17 + .../server/wss/docker-container-logs.ts | 4 + .../server/wss/docker-container-terminal.ts | 4 + apps/dokploy/server/wss/listen-deployment.ts | 4 + apps/dokploy/server/wss/terminal.ts | 4 + packages/server/src/services/known-secrets.ts | 153 ++++++++ .../server/src/utils/security/redaction.ts | 176 +++++++-- .../server/src/utils/security/secret-scrub.ts | 278 ++++++++++++++ 18 files changed, 1592 insertions(+), 46 deletions(-) create mode 100644 apps/dokploy/__test__/security/api-key-output-guard.test.ts create mode 100644 apps/dokploy/__test__/security/known-secrets.test.ts create mode 100644 apps/dokploy/__test__/security/secret-scrub.test.ts create mode 100644 apps/dokploy/__test__/wss/api-key.test.ts create mode 100644 apps/dokploy/server/api/utils/api-key-output-guard.ts create mode 100644 apps/dokploy/server/wss/api-key.ts create mode 100644 packages/server/src/services/known-secrets.ts create mode 100644 packages/server/src/utils/security/secret-scrub.ts diff --git a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts index b6db0b767f4..110c23bb764 100644 --- a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts +++ b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts @@ -205,6 +205,17 @@ vi.mock("ai", () => ({ generateText: vi.fn(), })); +vi.mock("@dokploy/server/services/known-secrets", async () => { + const { buildKnownSecretMatcher } = await import( + "@dokploy/server/utils/security/secret-scrub" + ); + return { + getKnownSecretMatcher: async () => + buildKnownSecretMatcher(["Zx9-sentinel-Value42"]), + resetKnownSecretMatcherCache: () => {}, + }; +}); + vi.mock("@dokploy/server/db", () => ({ db: { query: {}, @@ -539,6 +550,36 @@ describe("deploy target placement ownership boundary", () => { ); }); + it("sends logs to the AI provider without secret values", async () => { + vi.mocked(selectAIProvider).mockReturnValueOnce( + vi.fn(() => "model") as never, + ); + vi.mocked(generateText).mockResolvedValueOnce({ + text: "ok", + } as Awaited>); + aiServiceMocks.getAiSettingById.mockResolvedValueOnce({ + aiId: "ai-1", + apiKey: "secret", + apiUrl: "https://api.openai.com/v1", + isEnabled: true, + model: "gpt-4o-mini", + name: "OpenAI", + organizationId: "org-1", + }); + + await aiRouter.createCaller(createContext("admin")).analyzeLogs({ + aiId: "ai-1", + context: "runtime", + logs: "connect failed with Zx9-sentinel-Value42\nDB_PASS=hunter2-hunter2\nlistening on 3000", + }); + + const prompt = vi.mocked(generateText).mock.calls.at(-1)?.[0].prompt; + expect(prompt).toContain("listening on 3000"); + expect(prompt).toContain("connect failed with __DOKPLOY_REDACTED_SECRET__"); + expect(prompt).not.toContain("Zx9-sentinel-Value42"); + expect(prompt).not.toContain("hunter2-hunter2"); + }); + it("denies AI suggestions for inaccessible servers before server metadata reaches AI service", async () => { await expect( aiRouter.createCaller(createContext()).suggest({ diff --git a/apps/dokploy/__test__/security/api-key-output-guard.test.ts b/apps/dokploy/__test__/security/api-key-output-guard.test.ts new file mode 100644 index 00000000000..f1c73d3b34c --- /dev/null +++ b/apps/dokploy/__test__/security/api-key-output-guard.test.ts @@ -0,0 +1,282 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +const SENTINEL = "Zx9-sentinel-Value42"; + +const mocks = vi.hoisted(() => ({ + getKnownSecretMatcher: vi.fn(), + resetKnownSecretMatcherCache: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); + +vi.mock("@dokploy/server/services/known-secrets", () => ({ + getKnownSecretMatcher: mocks.getKnownSecretMatcher, + resetKnownSecretMatcherCache: mocks.resetKnownSecretMatcherCache, +})); + +vi.mock("@dokploy/server/services/super-password", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/super-password") + >()), + checkSuperSessionAccess: vi.fn(async () => null), +})); + +const { buildKnownSecretMatcher } = await import( + "@dokploy/server/utils/security/secret-scrub" +); +const { createTRPCRouter, protectedProcedure } = await import( + "@/server/api/trpc" +); +const { isProcedureDeniedForApiKey } = await import( + "@/server/api/utils/api-key-output-guard" +); +const { PROCEDURE_ACCESS } = await import( + "@/server/api/utils/procedure-access" +); + +const handled = vi.fn(); + +const service = { + applicationId: "app-1", + name: "api", + env: `API_TOKEN=${SENTINEL}`, + environment: { name: "production", env: `SHARED=${SENTINEL}` }, +}; + +const secretReader = protectedProcedure.mutation(() => { + handled(); + return { env: SENTINEL }; +}); + +const testRouter = createTRPCRouter({ + application: createTRPCRouter({ + one: protectedProcedure.query(() => service), + readLogs: protectedProcedure.query( + () => `boot ok\nusing token ${SENTINEL}\nDB_PASS=hunter2-hunter2`, + ), + update: protectedProcedure + .input(z.object({ command: z.string(), env: z.string().optional() })) + .mutation(({ input }) => { + handled(input); + return true; + }), + fail: protectedProcedure.query(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `docker login failed for ${SENTINEL}`, + }); + }), + deployWithLogs: protectedProcedure.subscription(async function* () { + yield `pulling with ${SENTINEL}`; + yield "done"; + }), + revision: protectedProcedure.query(() => ({ + revision: "env:Abc123-revision", + expectedRevision: "env:Abc123-revision", + })), + revealEnvironment: secretReader, + }), + postgres: createTRPCRouter({ revealEnvironment: secretReader }), + docker: createTRPCRouter({ + getConfig: secretReader, + readContainerFile: secretReader, + }), + dockerVolume: createTRPCRouter({ readVolumeFile: secretReader }), + settings: createTRPCRouter({ + readTraefikEnv: secretReader, + getOpenApiDocument: protectedProcedure.query(() => ({ + properties: { password: { type: "string" }, env: { type: "string" } }, + })), + }), + user: createTRPCRouter({ revealApiKey: secretReader }), +}); + +const owner = { + id: "user-1", + email: "owner@example.test", + role: "owner" as const, + ownerId: "user-1", +}; + +const callerFor = (authMethod: "session" | "api-key") => + testRouter.createCaller({ + db: {}, + req: { headers: {} }, + res: {}, + user: owner, + session: { + id: "session-1", + userId: owner.id, + activeOrganizationId: "org-1", + authMethod, + }, + } as never); + +const apiKey = () => callerFor("api-key"); +const browser = () => callerFor("session"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getKnownSecretMatcher.mockResolvedValue( + buildKnownSecretMatcher([SENTINEL]), + ); +}); + +describe("API key responses", () => { + it("never carry secret fields, even for the owner", async () => { + const result = await apiKey().application.one(); + + expect(result).toEqual({ + applicationId: "app-1", + name: "api", + env: R, + environment: { name: "production", env: R }, + }); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + }); + + it("leave browser sessions untouched", async () => { + await expect(browser().application.one()).resolves.toEqual(service); + await expect(browser().application.readLogs()).resolves.toContain(SENTINEL); + expect(mocks.getKnownSecretMatcher).not.toHaveBeenCalled(); + }); + + it("scrub known values and credential-looking text out of logs", async () => { + await expect(apiKey().application.readLogs()).resolves.toBe( + `boot ok\nusing token ${R}\nDB_PASS=${R}`, + ); + }); + + it("scrub error messages", async () => { + const error = await apiKey() + .application.fail() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + expect((error as TRPCError).message).toBe(`docker login failed for ${R}`); + }); + + it("scrub every chunk of a subscription", async () => { + const stream = + (await apiKey().application.deployWithLogs()) as unknown as AsyncIterable; + const chunks: string[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks).toEqual([`pulling with ${R}`, "done"]); + }); + + it("leave the OpenAPI document as it is", async () => { + const document = await apiKey().settings.getOpenApiDocument(); + + expect(document).toEqual({ + properties: { password: { type: "string" }, env: { type: "string" } }, + }); + }); + + it("keep revisions intact", async () => { + const matcher = buildKnownSecretMatcher(["env:Abc123-revision"]); + mocks.getKnownSecretMatcher.mockResolvedValue(matcher); + + await expect(apiKey().application.revision()).resolves.toEqual({ + revision: "env:Abc123-revision", + expectedRevision: "env:Abc123-revision", + }); + }); + + it("fail closed when the known secrets cannot be loaded", async () => { + mocks.getKnownSecretMatcher.mockRejectedValue(new Error("db down")); + + await expect(apiKey().application.one()).rejects.toThrow(); + }); +}); + +describe("procedures that return raw secrets", () => { + it.each([ + [ + "application.revealEnvironment", + () => apiKey().application.revealEnvironment(), + ], + ["postgres.revealEnvironment", () => apiKey().postgres.revealEnvironment()], + ["docker.getConfig", () => apiKey().docker.getConfig()], + ["docker.readContainerFile", () => apiKey().docker.readContainerFile()], + [ + "dockerVolume.readVolumeFile", + () => apiKey().dockerVolume.readVolumeFile(), + ], + ["settings.readTraefikEnv", () => apiKey().settings.readTraefikEnv()], + ["user.revealApiKey", () => apiKey().user.revealApiKey()], + ])("%s is refused before it runs", async (_path, call) => { + await expect(call()).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(handled).not.toHaveBeenCalled(); + }); + + it("stay available to browser sessions", async () => { + await expect(browser().application.revealEnvironment()).resolves.toEqual({ + env: SENTINEL, + }); + }); + + it("cover every reveal procedure of the app router", () => { + const reveals = Object.keys(PROCEDURE_ACCESS).filter((path) => + /reveal/i.test(path), + ); + + expect(reveals.length).toBeGreaterThan(0); + for (const path of reveals) { + expect(isProcedureDeniedForApiKey(path), path).toBe(true); + } + }); +}); + +describe("known secrets cache", () => { + it("is dropped after every mutation, from an API key or a browser", async () => { + await apiKey().application.update({ command: "run" }); + await browser().application.update({ command: "run" }); + + expect(mocks.resetKnownSecretMatcherCache).toHaveBeenCalledTimes(2); + }); + + it("is kept after a query", async () => { + await apiKey().application.one(); + await browser().application.one(); + + expect(mocks.resetKnownSecretMatcherCache).not.toHaveBeenCalled(); + }); +}); + +describe("API key writes", () => { + it("reject text that was scrubbed on the way out", async () => { + await expect( + apiKey().application.update({ command: `run --token=${R}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: "run --token=[REDACTED]" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: R }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: "run", env: `A=1\nB=${R}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(handled).not.toHaveBeenCalled(); + }); + + it("accept a whole secret field sent back as the bare placeholder", async () => { + await expect( + apiKey().application.update({ command: "run", env: R }), + ).resolves.toBe(true); + expect(handled).toHaveBeenCalledWith({ command: "run", env: R }); + }); + + it("do not restrict browser sessions", async () => { + await expect( + browser().application.update({ command: `run --token=${R}` }), + ).resolves.toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/security/known-secrets.test.ts b/apps/dokploy/__test__/security/known-secrets.test.ts new file mode 100644 index 00000000000..03bbc772f50 --- /dev/null +++ b/apps/dokploy/__test__/security/known-secrets.test.ts @@ -0,0 +1,134 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: new Map[]>(), + selects: 0, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + select: () => ({ + from: async (table: unknown) => { + state.selects += 1; + return state.rows.get(table) ?? []; + }, + }), + }, +})); + +const schema = await import("@dokploy/server/db/schema"); +const { getKnownSecretMatcher, resetKnownSecretMatcherCache } = await import( + "@dokploy/server/services/known-secrets" +); +const { scrubSecretText } = await import( + "@dokploy/server/utils/security/secret-scrub" +); + +const TABLE_COUNT = 11; + +beforeEach(() => { + state.rows.clear(); + state.selects = 0; + resetKnownSecretMatcherCache(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getKnownSecretMatcher", () => { + it("collects secrets from every place the panel stores them", async () => { + state.rows.set(schema.applications, [ + { + env: "API_TOKEN=App-Env-Value-1", + previewEnv: "API_TOKEN=App-Preview-Value-1", + buildArgs: "NPM_TOKEN=App-BuildArg-Value-1", + buildSecrets: "SIGNING_KEY=App-BuildSecret-Value-1", + previewBuildArgs: "NPM_TOKEN=App-PreviewArg-Value-1", + previewBuildSecrets: "SIGNING_KEY=App-PreviewSecret-Value-1", + password: "App-Registry-Pass-1", + }, + ]); + state.rows.set(schema.compose, [{ env: "JWT_SECRET=Compose-Value-1" }]); + state.rows.set(schema.postgres, [ + { env: null, password: "Postgres-Pass-1" }, + ]); + state.rows.set(schema.mysql, [ + { env: "", password: "Mysql-Pass-1", rootPassword: "Mysql-Root-1" }, + ]); + state.rows.set(schema.mariadb, [ + { env: "", password: "Maria-Pass-1", rootPassword: "Maria-Root-1" }, + ]); + state.rows.set(schema.mongo, [{ env: "", password: "Mongo-Pass-1" }]); + state.rows.set(schema.redis, [{ env: "", password: "Redis-Pass-1" }]); + state.rows.set(schema.libsql, [{ env: "", password: "Libsql-Pass-1" }]); + state.rows.set(schema.projects, [{ env: "SHARED_KEY=Project-Value-1" }]); + state.rows.set(schema.environments, [ + { env: "SHARED_KEY=Environment-Value-1" }, + ]); + state.rows.set(schema.registry, [{ password: "Registry-Pass-1" }]); + + const matcher = await getKnownSecretMatcher(); + const secrets = [ + "App-Env-Value-1", + "App-Preview-Value-1", + "App-BuildArg-Value-1", + "App-BuildSecret-Value-1", + "App-PreviewArg-Value-1", + "App-PreviewSecret-Value-1", + "App-Registry-Pass-1", + "Compose-Value-1", + "Postgres-Pass-1", + "Mysql-Pass-1", + "Mysql-Root-1", + "Maria-Pass-1", + "Maria-Root-1", + "Mongo-Pass-1", + "Redis-Pass-1", + "Libsql-Pass-1", + "Project-Value-1", + "Environment-Value-1", + "Registry-Pass-1", + ]; + + expect(matcher.size).toBe(secrets.length); + expect(scrubSecretText(secrets.join(" | "), matcher)).toBe( + secrets.map(() => R).join(" | "), + ); + }); + + it("reuses the result until the cache expires or is reset", async () => { + vi.useFakeTimers(); + + await getKnownSecretMatcher(); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT); + + vi.advanceTimersByTime(31_000); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT * 2); + + resetKnownSecretMatcherCache(); + await getKnownSecretMatcher(); + expect(state.selects).toBe(TABLE_COUNT * 3); + }); + + it("shares one load between concurrent callers", async () => { + await Promise.all([getKnownSecretMatcher(), getKnownSecretMatcher()]); + + expect(state.selects).toBe(TABLE_COUNT); + }); + + it("does not cache a failed load", async () => { + const failure = new Error("db down"); + const original = state.rows.get; + state.rows.get = () => { + throw failure; + }; + await expect(getKnownSecretMatcher()).rejects.toThrow("db down"); + + state.rows.get = original; + await expect(getKnownSecretMatcher()).resolves.toMatchObject({ size: 0 }); + }); +}); diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts new file mode 100644 index 00000000000..c2cc08ef7e4 --- /dev/null +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -0,0 +1,343 @@ +import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { + buildKnownSecretMatcher, + collectEnvSecretValues, + collectSecretValue, + isSensitiveOutputKey, + scrubSecretOutput, + scrubSecretText, +} from "@dokploy/server/utils/security/secret-scrub"; +import { describe, expect, it } from "vitest"; + +const SENTINEL = "Zx9-sentinel-Value42"; + +describe("isSensitiveOutputKey", () => { + it.each([ + "env", + "previewEnv", + "Env", + "buildArgs", + "buildSecrets", + "previewBuildSecrets", + "password", + "databaseRootPassword", + "refreshToken", + "metricsToken", + "clientSecret", + "privateKey", + "apiKey", + "accessKey", + "secretAccessKey", + "webhookUrl", + "headers", + "licenseKey", + "authorization", + ])("treats %s as secret-bearing", (key) => { + expect(isSensitiveOutputKey(key)).toBe(true); + }); + + it.each([ + "environment", + "environmentId", + "secretName", + "apiKeyId", + "tokenType", + "apiKeys", + "passwordUpdatedAt", + "publicKey", + "createEnvFile", + "composeFile", + "content", + "command", + ])("leaves %s to the text scrubber", (key) => { + expect(isSensitiveOutputKey(key)).toBe(false); + }); +}); + +describe("scrubSecretOutput", () => { + it("blanks secret fields at any depth and keeps the rest", () => { + const scrubbed = scrubSecretOutput({ + applicationId: "app-1", + name: "api", + env: `TOKEN=${SENTINEL}\nPORT=3000`, + environment: { + name: "production", + env: `SHARED=${SENTINEL}`, + project: { name: "shop", env: `ROOT=${SENTINEL}` }, + }, + registry: { registryUrl: "registry.example.test", password: SENTINEL }, + }); + + expect(scrubbed).toEqual({ + applicationId: "app-1", + name: "api", + env: R, + environment: { + name: "production", + env: R, + project: { name: "shop", env: R }, + }, + registry: { registryUrl: "registry.example.test", password: R }, + }); + }); + + it("keeps flags, numbers, nulls and empty strings under secret-looking keys", () => { + const value = { + variables: [{ name: "API_TOKEN", secret: true, empty: false }], + password: null, + env: "", + inputTokens: 12, + }; + + expect(scrubSecretOutput(value)).toEqual(value); + }); + + it("keeps the names of a docker Env list and of a header map", () => { + const scrubbed = scrubSecretOutput({ + Config: { + Env: [`API_TOKEN=${SENTINEL}`, "PATH=/usr/bin", "not an assignment"], + }, + headers: { Authorization: `Bearer ${SENTINEL}`, "X-Trace": "on" }, + }); + + expect(scrubbed).toEqual({ + Config: { Env: [`API_TOKEN=${R}`, `PATH=${R}`, R] }, + headers: { Authorization: R, "X-Trace": R }, + }); + }); + + it("never rewrites identifiers and names", () => { + const matcher = buildKnownSecretMatcher(["svc-auth-Prod1"]); + const value = { + appName: "svc-auth-Prod1", + name: "svc-auth-Prod1", + applicationId: "svc-auth-Prod1", + containerName: "svc-auth-Prod1.1.abc", + description: "runs svc-auth-Prod1", + }; + + expect(scrubSecretOutput(value, matcher)).toEqual({ + ...value, + description: `runs ${R}`, + }); + }); + + it("leaves dates and other non-plain values untouched", () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + + expect(scrubSecretOutput({ createdAt, count: 3, ok: true })).toEqual({ + createdAt, + count: 3, + ok: true, + }); + }); + + it("scrubs free text fields instead of blanking them", () => { + const scrubbed = scrubSecretOutput({ + composeFile: [ + "services:", + " api:", + " environment:", + " DATABASE_PASSWORD: hunter2-hunter2", + " SESSION_SECRET: ${SESSION_SECRET}", + " PORT: 3000", + ].join("\n"), + command: "server --api-token=abc123 --port 8080", + }) as { composeFile: string; command: string }; + + expect(scrubbed.composeFile).toContain(`DATABASE_PASSWORD: ${R}`); + expect(scrubbed.composeFile).toContain("SESSION_SECRET: ${SESSION_SECRET}"); + expect(scrubbed.composeFile).toContain("PORT: 3000"); + expect(scrubbed.command).toBe(`server --api-token=${R} --port 8080`); + }); +}); + +describe("scrubSecretText", () => { + it("removes known secret values wherever they appear", () => { + const matcher = buildKnownSecretMatcher([SENTINEL]); + + expect( + scrubSecretText( + `connecting with ${SENTINEL}\nretry ${SENTINEL}!`, + matcher, + ), + ).toBe(`connecting with ${R}\nretry ${R}!`); + }); + + it("replaces a secret that contains another one as a whole", () => { + const matcher = buildKnownSecretMatcher(["abcd1234", "abcd1234-wxyz5678"]); + + expect(scrubSecretText("key abcd1234-wxyz5678 end", matcher)).toBe( + `key ${R} end`, + ); + }); + + it.each([ + ["DB_PASS=s3cr3tValue", `DB_PASS=${R}`], + ["export JWT_SIGNING_KEY='abc def'", `export JWT_SIGNING_KEY=${R}`], + ["SESSION_SALT=qwerty", `SESSION_SALT=${R}`], + ["SENTRY_DSN=https://key@sentry.example.test/1", `SENTRY_DSN=${R}`], + ['{"client_secret": "abc"}', `{"client_secret": ${R}}`], + ["postgres://app:pa55word@db:5432/app", `postgres://${R}@db:5432/app`], + ["Authorization: Bearer abc.def.ghi", `Authorization: ${R}`], + [`token ghp_${"a".repeat(36)} used`, `token ${R} used`], + [`github_pat_${"A1".repeat(20)}`, R], + [`glpat-${"b".repeat(20)}`, R], + ["xoxb-1234567890-abcdefghij", R], + [`sk-${"c".repeat(24)}`, R], + ["AKIAIOSFODNN7EXAMPLE", R], + [`eyJ${"a".repeat(12)}.eyJ${"b".repeat(12)}.${"c".repeat(12)}`, R], + ])("scrubs %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it("removes a private key block, even a truncated one", () => { + const block = + "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nIBAAK\n-----END RSA PRIVATE KEY-----"; + + expect(scrubSecretText(`before\n${block}\nafter`)).toBe( + `before\n${R}\nafter`, + ); + expect( + scrubSecretText("x\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3Blbn"), + ).toBe(`x\n${R}`); + }); + + it.each([ + "DB_PASSWORD=${DB_PASSWORD}", + "API_TOKEN=$API_TOKEN", + "JWT_SECRET: ${{project.JWT_SECRET}}", + 'SESSION_SECRET: "${SESSION_SECRET:?required}"', + "postgres://${DB_USER}:${DB_PASSWORD}@db:5432/app", + ])("keeps the variable reference in %s", (input) => { + expect(scrubSecretText(input)).toBe(input); + }); + + it("scrubs the inline default of a variable reference", () => { + expect(scrubSecretText("DB_PASSWORD=${DB_PASSWORD:-hunter2}")).toBe( + `DB_PASSWORD=${R}`, + ); + }); + + it.each([ + "redis://session-store:6379/0", + "https://auth.example.test:8443/login", + "SMTP_HOST: mail.auth.example:587", + "upstream token-service:8080 is healthy", + "listening on port 3000, workers: 4", + ])("does not read a host and port as a key and value in %s", (input) => { + expect(scrubSecretText(input)).toBe(input); + }); + + it.each([ + ['{"my secret key": "abc"}', `{"my secret key": ${R}}`], + ["password:abc123", `password:${R}`], + [" - API_KEY: abc", ` - API_KEY: ${R}`], + [ + "?next=/home&access-token-v2=abc&x=1", + `?next=/home&access-token-v2=${R}&x=1`, + ], + ])("scrubs the value in %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it("scrubs a very long line piece by piece without losing text", () => { + const filler = "lorem ipsum dolor sit amet ".repeat(400); + const line = `${filler}DB_PASS=first-Secret1 ${filler}{"api_key":"second-Secret2"},${"x".repeat(9000)}&token=third-Secret3`; + const scrubbed = scrubSecretText( + `head\n${line}\ntail DB_PASS=fourth-Secret4`, + ); + + expect(scrubbed).not.toMatch(/(?:first|second|third|fourth)-Secret/); + expect(scrubbed.startsWith(`head\n${filler}DB_PASS=${R} ${filler}`)).toBe( + true, + ); + expect(scrubbed.endsWith(`\ntail DB_PASS=${R}`)).toBe(true); + expect(scrubSecretText(`${filler}${filler}`)).toBe(`${filler}${filler}`); + }); + + it.each([ + ["dashes", "ab-".repeat(200_000)], + ["dots", "ab.cd".repeat(120_000)], + ["assignments", "A=".repeat(300_000)], + ["keywords", "pass-".repeat(120_000)], + ["urls", "a://b".repeat(120_000)], + ["jwt heads", "eyJ-".repeat(150_000)], + ["token prefixes", "sk-".repeat(200_000)], + ["flags", " --a".repeat(150_000)], + ["clients", "mysql ".repeat(100_000)], + ])("stays fast on a hostile line of %s", (_name, input) => { + const startedAt = performance.now(); + + scrubSecretText(input); + + expect(performance.now() - startedAt).toBeLessThan(3_000); + }); + + it("is idempotent", () => { + const once = scrubSecretText( + "DB_PASS=abc postgres://u:p4ssw0rd@h/db Authorization: Bearer x.y.z", + ); + + expect(scrubSecretText(once)).toBe(once); + }); + + it("handles a large log quickly", () => { + const matcher = buildKnownSecretMatcher( + Array.from({ length: 300 }, (_, index) => `Secret-${index}-Value9`), + ); + const line = `2026-01-01T00:00:00Z info request id=42 path=/health status=200 ${SENTINEL}\n`; + const log = line.repeat(12_000); + const startedAt = performance.now(); + + const scrubbed = scrubSecretText(log, buildKnownSecretMatcher([SENTINEL])); + scrubSecretText(log, matcher); + + expect(performance.now() - startedAt).toBeLessThan(5_000); + expect(scrubbed).not.toContain(SENTINEL); + }); +}); + +describe("known secret values", () => { + it("collects secret-looking variables, opaque values and URL passwords", () => { + const opaque = "a1".repeat(20); + const values = collectEnvSecretValues( + [ + `API_TOKEN=${SENTINEL}`, + `BUILD_STAMP=${opaque}`, + "DATABASE_URL=postgres://app:Pa55-word@db:5432/app", + "PORT=3000", + "NODE_ENV=production", + "APP_URL=https://app.example.test", + ].join("\n"), + ); + + expect(values.sort()).toEqual([SENTINEL, opaque, "Pa55-word"].sort()); + }); + + it("skips values that would mangle ordinary output", () => { + expect(collectSecretValue("DB_PASSWORD", "postgres")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "1234567890")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "short1")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "two words here")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", "${DB_PASSWORD}")).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", R)).toEqual([]); + expect(collectSecretValue("DB_PASSWORD", null)).toEqual([]); + }); + + it("also scrubs the decoded form of a URL-encoded password", () => { + expect( + collectSecretValue( + "DATABASE_URL", + "mysql://app:p%40ss-W0rd@db/app", + ).sort(), + ).toEqual(["p%40ss-W0rd", "p@ss-W0rd"].sort()); + }); + + it("returns an empty matcher when nothing qualifies", () => { + const matcher = buildKnownSecretMatcher(["short", "postgres"]); + + expect(matcher.size).toBe(0); + expect(scrubSecretText("postgres short", matcher)).toBe("postgres short"); + }); +}); diff --git a/apps/dokploy/__test__/security/super-session-guard.test.ts b/apps/dokploy/__test__/security/super-session-guard.test.ts index e309b20cc98..1ba696b0318 100644 --- a/apps/dokploy/__test__/security/super-session-guard.test.ts +++ b/apps/dokploy/__test__/security/super-session-guard.test.ts @@ -41,6 +41,16 @@ vi.mock("@/server/api/utils/audit", () => ({ audit: mocks.audit, })); +vi.mock("@dokploy/server/services/known-secrets", async () => { + const { EMPTY_KNOWN_SECRET_MATCHER } = await import( + "@dokploy/server/utils/security/secret-scrub" + ); + return { + getKnownSecretMatcher: async () => EMPTY_KNOWN_SECRET_MATCHER, + resetKnownSecretMatcherCache: () => {}, + }; +}); + const schema = await import("@dokploy/server/db/schema"); const { fake } = (await import("@dokploy/server/db")) as unknown as { fake: ReturnType< @@ -162,9 +172,6 @@ describe("super session guard without a super password", () => { await expect(apiKeyCaller().application.update()).resolves.toBe( "application.update", ); - await expect(apiKeyCaller().application.revealEnvironment()).resolves.toBe( - "application.revealEnvironment", - ); await expect(browserCaller().application.revealEnvironment()).resolves.toBe( "application.revealEnvironment", ); @@ -172,6 +179,17 @@ describe("super session guard without a super password", () => { browserCaller().user.update({ email: "new@example.test" }), ).resolves.toBe("user.update"); }); + + it("never reveals secrets to API keys, with or without a super password", async () => { + await expect( + apiKeyCaller().application.revealEnvironment(), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "This procedure returns secret values and is not available to API keys", + }); + expect(handled).not.toHaveBeenCalled(); + }); }); describe("super session guard with a super password", () => { diff --git a/apps/dokploy/__test__/wss/api-key.test.ts b/apps/dokploy/__test__/wss/api-key.test.ts new file mode 100644 index 00000000000..fd6afdc20f3 --- /dev/null +++ b/apps/dokploy/__test__/wss/api-key.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { rejectApiKeySession } from "../../server/wss/api-key"; + +describe("rejectApiKeySession", () => { + it("closes a socket opened with an API key", () => { + const ws = { close: vi.fn() }; + + expect(rejectApiKeySession(ws, { authMethod: "api-key" })).toBe(true); + expect(ws.close).toHaveBeenCalledWith(4003, "Not available to API keys"); + }); + + it.each([{ authMethod: "session" }, {}, null, undefined])( + "lets a browser session through (%o)", + (session) => { + const ws = { close: vi.fn() }; + + expect(rejectApiKeySession(ws, session)).toBe(false); + expect(ws.close).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/dokploy/pages/api/application.env.upsert.ts b/apps/dokploy/pages/api/application.env.upsert.ts index 361913afb2d..71697e67d34 100644 --- a/apps/dokploy/pages/api/application.env.upsert.ts +++ b/apps/dokploy/pages/api/application.env.upsert.ts @@ -5,6 +5,7 @@ import { validateRequest, } from "@dokploy/server"; import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema"; +import { resetKnownSecretMatcherCache } from "@dokploy/server/services/known-secrets"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; import { checkSuperSessionAccess, @@ -114,6 +115,7 @@ export const handleApplicationEnvUpsert = async ( let redeployed = false; if (!result.dryRun && result.changed) { + resetKnownSecretMatcherCache(); const application = await findApplicationById(input.applicationId); await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 9344eac4da3..4badf8f4285 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -20,6 +20,7 @@ import { suggestVariants, } from "@dokploy/server/services/ai"; import { createComposeByTemplate } from "@dokploy/server/services/compose"; +import { getKnownSecretMatcher } from "@dokploy/server/services/known-secrets"; import { addNewService, checkPermission, @@ -36,6 +37,7 @@ import { redactAiSettingsSecrets, redactAiSettingsSecretsList, } from "@dokploy/server/utils/security/redaction"; +import { scrubSecretText } from "@dokploy/server/utils/security/secret-scrub"; import { fetchWithPublicEgress } from "@dokploy/server/utils/url/network"; import { TRPCError } from "@trpc/server"; import { generateText } from "ai"; @@ -302,6 +304,8 @@ export const aiRouter = createTRPCRouter({ const contextLabel = input.context === "build" ? "build/deployment" : "runtime/container"; + // The logs go to a third-party model, so they leave without secrets. + const logs = scrubSecretText(input.logs, await getKnownSecretMatcher()); const result = await generateText({ model, @@ -315,7 +319,7 @@ export const aiRouter = createTRPCRouter({ Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly. Logs: -${input.logs}`, +${logs}`, }); return { analysis: result.text }; diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index 9d0c388d92d..60464e59cd6 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -11,6 +11,7 @@ import { db } from "@dokploy/server/db"; import type { statements } from "@dokploy/server/lib/access-control"; import { validateRequest } from "@dokploy/server/lib/auth"; +import { resetKnownSecretMatcherCache } from "@dokploy/server/services/known-secrets"; import { checkPermission } from "@dokploy/server/services/permission"; import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; import { getSuperSessionDenial } from "@dokploy/server/services/super-password"; @@ -20,7 +21,16 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import type { Session, User } from "better-auth"; import superjson from "superjson"; import { ZodError } from "zod"; -import { enforceProcedureSuperSession } from "@/server/api/utils/super-session"; +import { + assertNoRedactedTextInInput, + assertProcedureAllowedForApiKey, + scrubApiKeyError, + scrubApiKeyResult, +} from "@/server/api/utils/api-key-output-guard"; +import { + enforceProcedureSuperSession, + isApiKeySession, +} from "@/server/api/utils/super-session"; type Resource = keyof typeof statements; type ActionOf = (typeof statements)[R][number]; @@ -161,7 +171,33 @@ const superSessionGuard = t.middleware( }, ); -const baseProcedure = t.procedure.use(superSessionGuard); +// An API key is the channel scripts and AI agents use, so nothing it receives +// may contain a secret value, whatever role the key's owner has. +const apiKeyOutputGuard = t.middleware( + async ({ ctx, path, type, getRawInput, next }) => { + const viaApiKey = isApiKeySession(ctx.session); + if (viaApiKey) { + assertProcedureAllowedForApiKey(path); + if (type === "mutation") { + assertNoRedactedTextInInput(await getRawInput()); + } + } + const result = await next(); + // Any write may have stored a new secret, whoever made it. + if (type === "mutation") { + resetKnownSecretMatcherCache(); + } + if (!viaApiKey) { + return result; + } + if (result.ok) { + return { ...result, data: await scrubApiKeyResult(path, result.data) }; + } + return { ...result, error: await scrubApiKeyError(result.error) }; + }, +); + +const baseProcedure = t.procedure.use(superSessionGuard).use(apiKeyOutputGuard); /** * Public (unauthenticated) procedure diff --git a/apps/dokploy/server/api/utils/api-key-output-guard.ts b/apps/dokploy/server/api/utils/api-key-output-guard.ts new file mode 100644 index 00000000000..bf8e5303721 --- /dev/null +++ b/apps/dokploy/server/api/utils/api-key-output-guard.ts @@ -0,0 +1,105 @@ +import { getKnownSecretMatcher } from "@dokploy/server/services/known-secrets"; +import { + isExactSecretPlaceholder, + isSecretPlaceholderValue, +} from "@dokploy/server/utils/security/redaction"; +import { + EMPTY_KNOWN_SECRET_MATCHER, + isSensitiveOutputKey, + scrubSecretOutput, + scrubSecretText, +} from "@dokploy/server/utils/security/secret-scrub"; +import { TRPCError } from "@trpc/server"; + +// These hand back a stored secret or raw file bytes, which no scrubbing can +// make safe (a container file may be nothing but one token), so an API key +// cannot call them at all. +const API_KEY_DENIED_PROCEDURES = new Set([ + "docker.getConfig", + "docker.readContainerFile", + "dockerVolume.readVolumeFile", + "settings.readTraefikEnv", + "user.revealApiKey", +]); + +// The OpenAPI document describes fields named `password` or `env` without +// holding a value, and blanking it would break the clients generated from it. +const API_KEY_UNSCRUBBED_PROCEDURES = new Set(["settings.getOpenApiDocument"]); + +export const isProcedureDeniedForApiKey = (path: string) => + API_KEY_DENIED_PROCEDURES.has(path) || path.endsWith(".revealEnvironment"); + +export const assertProcedureAllowedForApiKey = (path: string) => { + if (isProcedureDeniedForApiKey(path)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "This procedure returns secret values and is not available to API keys", + }); + } +}; + +const findRedactedText = (value: unknown, key: string | null): boolean => { + if (typeof value === "string") { + if (!isSecretPlaceholderValue(value)) { + return false; + } + return !( + key !== null && + isSensitiveOutputKey(key) && + isExactSecretPlaceholder(value) + ); + } + if (Array.isArray(value)) { + return value.some((item) => findRedactedText(item, key)); + } + if (value !== null && typeof value === "object") { + return Object.entries(value).some(([childKey, child]) => + findRedactedText(child, childKey), + ); + } + return false; +}; + +// Text that was scrubbed on the way out must not come back in: saving it would +// overwrite the real value with the placeholder. A whole secret field sent +// back as the bare placeholder still means "keep what is stored". +export const assertNoRedactedTextInInput = (input: unknown) => { + if (findRedactedText(input, null)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The input contains redacted text. Read values are scrubbed for API keys and cannot be written back; change environment variables with env.apply instead", + }); + } +}; + +const isAsyncIterable = (value: unknown): value is AsyncIterable => + value !== null && + typeof value === "object" && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function"; + +async function* scrubStream(source: AsyncIterable) { + for await (const chunk of source) { + yield scrubSecretOutput(chunk, await getKnownSecretMatcher()); + } +} + +export const scrubApiKeyResult = async (path: string, data: unknown) => { + if (API_KEY_UNSCRUBBED_PROCEDURES.has(path)) { + return data; + } + return isAsyncIterable(data) + ? scrubStream(data) + : scrubSecretOutput(data, await getKnownSecretMatcher()); +}; + +export const scrubApiKeyError = async (error: TRPCError) => { + const matcher = await getKnownSecretMatcher().catch( + () => EMPTY_KNOWN_SECRET_MATCHER, + ); + const message = scrubSecretText(error.message, matcher); + return message === error.message + ? error + : new TRPCError({ code: error.code, message, cause: error.cause }); +}; diff --git a/apps/dokploy/server/wss/api-key.ts b/apps/dokploy/server/wss/api-key.ts new file mode 100644 index 00000000000..505bc387835 --- /dev/null +++ b/apps/dokploy/server/wss/api-key.ts @@ -0,0 +1,17 @@ +import type { WebSocket } from "ws"; + +type WssSession = { authMethod?: string | null } | null | undefined; + +// A shell or a raw log stream carries whatever the container prints, and a +// stream cannot be scrubbed of a secret split across two chunks. API keys read +// logs through the readLogs procedures, whose output is scrubbed. +export const rejectApiKeySession = ( + ws: Pick, + session: WssSession, +) => { + if (session?.authMethod !== "api-key") { + return false; + } + ws.close(4003, "Not available to API keys"); + return true; +}; diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index 38ae6a8b2b1..a662955b81a 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -4,6 +4,7 @@ import { resolveServerDestinationHost } from "@dokploy/server/utils/servers/dest import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerLogsWebSocket } from "./docker-permission"; import { verifyWebSocketOrigin } from "./origin"; @@ -53,6 +54,9 @@ export const setupDockerContainerLogsWebSocketServer = ( ws.close(); return; } + if (rejectApiKeySession(ws, session)) { + return; + } if (!containerId) { ws.close(4000, "containerId no provided"); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index e0042de8194..ec354937729 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,6 +4,7 @@ import { resolveServerDestinationHost } from "@dokploy/server/utils/servers/dest import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerTerminalWebSocket } from "./docker-permission"; import { verifyWebSocketOrigin } from "./origin"; @@ -95,6 +96,9 @@ export const setupDockerContainerTerminalWebSocketServer = ( if (await rejectTerminalWithoutSuperSession(ws, user, session)) { return; } + if (rejectApiKeySession(ws, session)) { + return; + } try { if (serverId) { const server = await findServerById(serverId); diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index 4e2a09d0190..dab12e4d6ea 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -18,6 +18,7 @@ import { } from "@dokploy/server/wss/utils"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { rejectApiKeySession } from "./api-key"; import { verifyWebSocketOrigin } from "./origin"; const getDeploymentLogPathRoot = ( @@ -222,6 +223,9 @@ export const setupDeploymentLogsWebSocketServer = ( ws.close(); return; } + if (rejectApiKeySession(ws, session)) { + return; + } if (!deploymentId) { ws.close(4000, "deploymentId required"); diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 2ef390c4579..00ce8f7ca63 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,6 +10,7 @@ import { publicIpv4, publicIpv6 } from "public-ip"; import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; +import { rejectApiKeySession } from "./api-key"; import { canAccessTerminalOverWss } from "./authorize"; import { verifyWebSocketOrigin } from "./origin"; import { canAccessServerTerminalWebSocket } from "./server-permission"; @@ -121,6 +122,9 @@ export const setupTerminalWebSocketServer = ( if (await rejectTerminalWithoutSuperSession(ws, user, session)) { return; } + if (rejectApiKeySession(ws, session)) { + return; + } let connectionDetails: ConnectConfig = {}; diff --git a/packages/server/src/services/known-secrets.ts b/packages/server/src/services/known-secrets.ts new file mode 100644 index 00000000000..c4d68146d42 --- /dev/null +++ b/packages/server/src/services/known-secrets.ts @@ -0,0 +1,153 @@ +import { db } from "@dokploy/server/db"; +import { + applications, + compose, + environments, + libsql, + mariadb, + mongo, + mysql, + postgres, + projects, + redis, + registry, +} from "@dokploy/server/db/schema"; +import { + buildKnownSecretMatcher, + collectEnvSecretValues, + collectSecretValue, + type KnownSecretMatcher, +} from "@dokploy/server/utils/security/secret-scrub"; + +// A secret saved a moment ago only reaches a container after a redeploy, so a +// short-lived cache cannot let it slip through a log read. +const CACHE_TTL_MS = 30_000; + +let cached: { matcher: KnownSecretMatcher; expiresAt: number } | null = null; +let pending: Promise | null = null; + +const loadKnownSecretValues = async () => { + const [ + applicationRows, + composeRows, + postgresRows, + mysqlRows, + mariadbRows, + mongoRows, + redisRows, + libsqlRows, + projectRows, + environmentRows, + registryRows, + ] = await Promise.all([ + db + .select({ + env: applications.env, + previewEnv: applications.previewEnv, + buildArgs: applications.buildArgs, + buildSecrets: applications.buildSecrets, + previewBuildArgs: applications.previewBuildArgs, + previewBuildSecrets: applications.previewBuildSecrets, + password: applications.password, + }) + .from(applications), + db.select({ env: compose.env }).from(compose), + db + .select({ env: postgres.env, password: postgres.databasePassword }) + .from(postgres), + db + .select({ + env: mysql.env, + password: mysql.databasePassword, + rootPassword: mysql.databaseRootPassword, + }) + .from(mysql), + db + .select({ + env: mariadb.env, + password: mariadb.databasePassword, + rootPassword: mariadb.databaseRootPassword, + }) + .from(mariadb), + db.select({ env: mongo.env, password: mongo.databasePassword }).from(mongo), + db.select({ env: redis.env, password: redis.databasePassword }).from(redis), + db + .select({ env: libsql.env, password: libsql.databasePassword }) + .from(libsql), + db.select({ env: projects.env }).from(projects), + db.select({ env: environments.env }).from(environments), + db.select({ password: registry.password }).from(registry), + ]); + + const values: string[] = []; + const addEnv = (...texts: (string | null | undefined)[]) => { + for (const text of texts) { + values.push(...collectEnvSecretValues(text)); + } + }; + const addPassword = (...passwords: (string | null | undefined)[]) => { + for (const password of passwords) { + values.push(...collectSecretValue("PASSWORD", password)); + } + }; + + for (const row of applicationRows) { + addEnv( + row.env, + row.previewEnv, + row.buildArgs, + row.buildSecrets, + row.previewBuildArgs, + row.previewBuildSecrets, + ); + addPassword(row.password); + } + for (const row of [...composeRows, ...projectRows, ...environmentRows]) { + addEnv(row.env); + } + for (const row of [ + ...postgresRows, + ...mongoRows, + ...redisRows, + ...libsqlRows, + ]) { + addEnv(row.env); + addPassword(row.password); + } + for (const row of [...mysqlRows, ...mariadbRows]) { + addEnv(row.env); + addPassword(row.password, row.rootPassword); + } + for (const row of registryRows) { + addPassword(row.password); + } + + return values; +}; + +/** + * Every secret value the panel stores for services, projects and environments, + * compiled into one matcher used to scrub API key responses. It covers the + * whole instance: scrubbing another organization's secret is harmless, missing + * one is not. + */ +export const getKnownSecretMatcher = async (): Promise => { + if (cached && cached.expiresAt > Date.now()) { + return cached.matcher; + } + pending ??= loadKnownSecretValues() + .then((values) => { + const matcher = buildKnownSecretMatcher(values); + cached = { matcher, expiresAt: Date.now() + CACHE_TTL_MS }; + return matcher; + }) + .finally(() => { + pending = null; + }); + return pending; +}; + +export const resetKnownSecretMatcherCache = () => { + cached = null; + pending = null; +}; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 88fea520436..777b232787c 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -21,6 +21,10 @@ export const isSecretPlaceholderValue = (value: unknown) => (value.includes(REDACTED_SECRET_VALUE) || value.includes(MCP_REDACTED_SECRET_VALUE)); +// Callers send a redacted field back unchanged to mean "keep what is stored". +export const isExactSecretPlaceholder = (value: unknown) => + value === REDACTED_SECRET_VALUE || value === MCP_REDACTED_SECRET_VALUE; + export const redactSecretValue = (value: T) => { if (value === null || value === undefined || value === "") { return value; @@ -392,66 +396,155 @@ export const redactProjectNestedSecrets = < return redactedProject as T; }; -export function redactSensitiveText(value: string): string; -export function redactSensitiveText(value: null): null; -export function redactSensitiveText(value: undefined): undefined; -export function redactSensitiveText(value: string | null): string | null; +type KeyPatterns = { + beforeAuthorization: RegExp[]; + afterAuthorization: RegExp[]; +}; + +const DEFAULT_KEY_PATTERNS: KeyPatterns = { + beforeAuthorization: [ + new RegExp( + `([?&#][^=\\s]*${SENSITIVE_KEY_PATTERN}[^=\\s]*=)([^&#\\s]+)`, + "gi", + ), + new RegExp( + `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)(${SHELL_VALUE_PATTERN})`, + "gi", + ), + ], + afterAuthorization: [ + new RegExp( + `((?:"[^"]*${SENSITIVE_KEY_PATTERN}[^"]*"|'[^']*${SENSITIVE_KEY_PATTERN}[^']*'|\\b[^\\s:,{]*${SENSITIVE_KEY_PATTERN}[^\\s:,{]*\\b)\\s*:\\s*)("[^"]*"|'[^']*'|[^\\s,}\\]]+)`, + "gi", + ), + // 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. + new RegExp( + `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))(${SHELL_VALUE_PATTERN})`, + "gi", + ), + ], +}; + +// Env-style names that hold secrets without saying "secret" or "token". +const BROAD_SENSITIVE_KEY_PATTERN = + "(?:access[_-]?key|api[_-]?key|auth|credential|private[_-]?key|secret|token|pass|pwd|dsn|cookie|session|signature|salt|webhook|license|encrypt|jwt|[_-]key)"; + +// A `${...}` value is taken whole, so redacting it leaves no stray brace. +const BRACED_VALUE_PATTERN = String.raw`(?:\$\{\{[^}\s]*\}\}|\$\{[^}\s]*\})+`; +const KEY_VALUE_PATTERN = `${BRACED_VALUE_PATTERN}|"[^"]*"|'[^']*'|[^\\s,}\\]]+`; + +// The broad patterns run over whole logs and files, so each one anchors on the +// keyword and bounds what surrounds it: scanning a free prefix from every word +// boundary is quadratic on a long line without spaces. A key also has to look +// like a key, or the short keywords would read `auth-db:5432` in a URL as the +// key `auth-db` with the value `5432`. +const BROAD_KEY_PATTERNS: KeyPatterns = { + beforeAuthorization: [ + new RegExp( + `(${BROAD_SENSITIVE_KEY_PATTERN}[^=\\s&#?]{0,80}=)(${SHELL_VALUE_PATTERN})`, + "gi", + ), + ], + afterAuthorization: [ + new RegExp( + `(${BROAD_SENSITIVE_KEY_PATTERN}[^"'\\n]{0,80}["']\\s*:\\s*)(${KEY_VALUE_PATTERN})`, + "gi", + ), + new RegExp( + `(?<=(? { + const quoted = /^(["'])([\s\S]*)\1$/.exec(value); + return VARIABLE_REFERENCE_VALUE.test(quoted ? quoted[2]! : value); +}; + +const isVariableReferenceUserinfo = (userinfo: string) => + userinfo.split(":").every((part) => isVariableReference(part)); + +export type RedactSensitiveTextOptions = { + keepVariableReferences?: boolean; + broadKeys?: boolean; +}; + +export function redactSensitiveText( + value: string, + options?: RedactSensitiveTextOptions, +): string; +export function redactSensitiveText( + value: null, + options?: RedactSensitiveTextOptions, +): null; +export function redactSensitiveText( + value: undefined, + options?: RedactSensitiveTextOptions, +): undefined; +export function redactSensitiveText( + value: string | null, + options?: RedactSensitiveTextOptions, +): string | null; export function redactSensitiveText( value: string | undefined, + options?: RedactSensitiveTextOptions, ): string | undefined; export function redactSensitiveText( value: string | null | undefined, + options?: RedactSensitiveTextOptions, ): string | null | undefined; -export function redactSensitiveText(value: string | null | undefined) { +export function redactSensitiveText( + value: string | null | undefined, + options: RedactSensitiveTextOptions = {}, +) { if (typeof value !== "string" || value === "") { return value; } - let redacted = value; + const keyPatterns = options.broadKeys + ? BROAD_KEY_PATTERNS + : DEFAULT_KEY_PATTERNS; + const redactValue = (match: string, prefix: string, secret: string) => + options.keepVariableReferences && isVariableReference(secret) + ? match + : `${prefix}${REDACTED_SECRET_VALUE}`; - redacted = redacted.replace( - /(\b[a-z][a-z0-9+.-]*:\/\/)([^@\s/?#]+)@/gi, - `$1${REDACTED_SECRET_VALUE}@`, - ); + let redacted = value; redacted = redacted.replace( - new RegExp( - `([?&#][^=\\s]*${SENSITIVE_KEY_PATTERN}[^=\\s]*=)([^&#\\s]+)`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, + /(\b[a-z][a-z0-9+.-]{0,30}:\/\/)([^@\s/?#]+)@/gi, + (match, scheme: string, userinfo: string) => + options.keepVariableReferences && isVariableReferenceUserinfo(userinfo) + ? match + : `${scheme}${REDACTED_SECRET_VALUE}@`, ); - redacted = redacted.replace( - new RegExp( - `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)(${SHELL_VALUE_PATTERN})`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, - ); + for (const pattern of keyPatterns.beforeAuthorization) { + redacted = redacted.replace(pattern, redactValue); + } redacted = redacted.replace( /(\bAuthorization\s*:\s*)(?:Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, `$1${REDACTED_SECRET_VALUE}`, ); - redacted = redacted.replace( - new RegExp( - `((?:"[^"]*${SENSITIVE_KEY_PATTERN}[^"]*"|'[^']*${SENSITIVE_KEY_PATTERN}[^']*'|\\b[^\\s:,{]*${SENSITIVE_KEY_PATTERN}[^\\s:,{]*\\b)\\s*:\\s*)("[^"]*"|'[^']*'|[^\\s,}\\]]+)`, - "gi", - ), - `$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(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))(${SHELL_VALUE_PATTERN})`, - "gi", - ), - `$1${REDACTED_SECRET_VALUE}`, - ); + for (const pattern of keyPatterns.afterAuthorization) { + redacted = redacted.replace(pattern, redactValue); + } // mysql/mariadb clients take the password glued to -p. A separate word after // -p is not a password, uppercase -P is the port, and -p of other tools @@ -486,7 +579,10 @@ export function redactSensitiveText(value: string | null | undefined) { redacted = redacted.replace( /(\b(?:postgres(?:ql)?|mysql|mariadb|mongodb|redis):\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, - `$1${REDACTED_SECRET_VALUE}$3`, + (match, prefix: string, password: string, at: string) => + options.keepVariableReferences && isVariableReference(password) + ? match + : `${prefix}${REDACTED_SECRET_VALUE}${at}`, ); redacted = redacted.replace( diff --git a/packages/server/src/utils/security/secret-scrub.ts b/packages/server/src/utils/security/secret-scrub.ts new file mode 100644 index 00000000000..7d500a3c133 --- /dev/null +++ b/packages/server/src/utils/security/secret-scrub.ts @@ -0,0 +1,278 @@ +import { parse } from "dotenv"; +import { + isVariableReference, + REDACTED_SECRET_VALUE, + redactSensitiveText, +} from "./redaction"; + +/** + * What leaves the panel towards an AI (an API key response, a prompt for the + * AI provider) never carries secret values: fields that hold secrets are + * blanked, and every other string (logs, compose files, mounted files, + * commands) is scrubbed of known secret values and of text that looks like a + * credential. + */ + +export type KnownSecretMatcher = { + size: number; + pattern: RegExp | null; +}; + +export const EMPTY_KNOWN_SECRET_MATCHER: KnownSecretMatcher = { + size: 0, + pattern: null, +}; + +const SENSITIVE_OUTPUT_KEY = + /(?:env$|buildargs$|buildsecrets$|password|passwd|passphrase|secret|tokens?$|private_?key|api_?key$|access_?key|credential|authorization|^cookie$|dsn$|connection_?string|webhook_?url$|^dockerauth$|license_?key$|^userkey$|^headers$)/i; + +// `secretName`, `apiKeyId` or `tokenType` describe a secret without holding it. +const DESCRIPTIVE_KEY_SUFFIX = + /(?:Id|Ids|Name|Names|Path|Type|Count|At|Enabled|Status|Length|Prefix)$/; + +// Identifiers and revisions are never secrets, and a caller that gets one back +// mangled can no longer address the resource or pass the revision on. +const IDENTITY_KEY = /(?:^id$|Id$|Ids$|^name$|Name$|^revision$|Revision$)/; + +const SECRET_ENV_NAME = + /(?:^|_)(?:AUTH|CERT|CONNECTION_STRING|COOKIE|CREDENTIALS?|DSN|ENCRYPTION|JWT|KEYS?|LICENSE|MNEMONIC|PASS|PASSPHRASE|PASSWD|PASSWORD|PRIVATE|PW|PWD|SALT|SECRETS?|SEED|SESSION|SIGNATURE|TOKENS?|WEBHOOK)(?:$|_)/i; + +const MIN_KNOWN_SECRET_LENGTH = 8; +const OPAQUE_VALUE = /^[A-Za-z0-9+/_=-]{32,}$/; +const URL_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/([^@\s/?#]+)@/i; + +export const isSensitiveOutputKey = (key: string) => + SENSITIVE_OUTPUT_KEY.test(key) && !DESCRIPTIVE_KEY_SUFFIX.test(key); + +// A value only goes on the scrub list when replacing it everywhere cannot +// mangle ordinary output: dictionary-like words and plain numbers stay out. +const isDistinctiveValue = (value: string) => + value.length >= MIN_KNOWN_SECRET_LENGTH && + !/^[a-z]+$/.test(value) && + !/^\d+$/.test(value) && + !/\s/.test(value) && + !value.includes(REDACTED_SECRET_VALUE) && + !isVariableReference(value); + +const userinfoSecrets = (value: string) => { + const userinfo = URL_USERINFO.exec(value)?.[1]; + if (!userinfo) { + return []; + } + const separator = userinfo.indexOf(":"); + const secret = separator === -1 ? userinfo : userinfo.slice(separator + 1); + let decoded = secret; + try { + decoded = decodeURIComponent(secret); + } catch {} + return [...new Set([secret, decoded])]; +}; + +export const collectSecretValue = (name: string, value: unknown): string[] => { + if (typeof value !== "string") { + return []; + } + const trimmed = value.trim(); + const candidates = userinfoSecrets(trimmed); + if ( + SECRET_ENV_NAME.test(name) || + (OPAQUE_VALUE.test(trimmed) && + /[A-Za-z]/.test(trimmed) && + /\d/.test(trimmed)) + ) { + candidates.push(trimmed); + } + return candidates.filter(isDistinctiveValue); +}; + +export const collectEnvSecretValues = ( + envText: string | null | undefined, +): string[] => { + if (!envText) { + return []; + } + return Object.entries(parse(envText)).flatMap(([name, value]) => + collectSecretValue(name, value), + ); +}; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export const buildKnownSecretMatcher = ( + values: Iterable, +): KnownSecretMatcher => { + const unique = [...new Set(values)].filter(isDistinctiveValue); + if (unique.length === 0) { + return EMPTY_KNOWN_SECRET_MATCHER; + } + // Longest first, so a secret that contains another one is replaced whole. + unique.sort((a, b) => b.length - a.length); + return { + size: unique.length, + pattern: new RegExp(unique.map(escapeRegExp).join("|"), "g"), + }; +}; + +const PRIVATE_KEY_BLOCK = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g; + +// A token starts where the run of token characters starts, so a line made of +// one long run is scanned once instead of once per prefix it contains. +const TOKEN_START = "(? new RegExp(`${TOKEN_START}${shape}`, "g")); + +// Some credential patterns scan to the end of the line, so one huge line (a +// minified bundle, a base64 dump) is scrubbed in pieces to keep the cost of a +// log read proportional to its size. +const MAX_SCRUB_PIECE_LENGTH = 4096; + +const hasLongLine = (text: string) => { + let lineStart = 0; + while (lineStart <= text.length) { + const lineEnd = text.indexOf("\n", lineStart); + if ( + (lineEnd === -1 ? text.length : lineEnd) - lineStart > + MAX_SCRUB_PIECE_LENGTH + ) { + return true; + } + if (lineEnd === -1) { + return false; + } + lineStart = lineEnd + 1; + } + return false; +}; + +const PIECE_DELIMITER = /[\s,;&|]/; + +// Cutting after a delimiter keeps a `name=value` pair in one piece. +const pieceEnd = (line: string) => { + for ( + let index = MAX_SCRUB_PIECE_LENGTH; + index > MAX_SCRUB_PIECE_LENGTH / 2; + index-- + ) { + if (PIECE_DELIMITER.test(line[index - 1]!)) { + return index; + } + } + return MAX_SCRUB_PIECE_LENGTH; +}; + +const splitLongLine = (line: string) => { + const pieces: string[] = []; + let rest = line; + while (rest.length > MAX_SCRUB_PIECE_LENGTH) { + const end = pieceEnd(rest); + pieces.push(rest.slice(0, end)); + rest = rest.slice(end); + } + pieces.push(rest); + return pieces; +}; + +const scrubCredentialPatterns = (text: string) => { + let scrubbed = text; + for (const shape of TOKEN_SHAPES) { + scrubbed = scrubbed.replace(shape, REDACTED_SECRET_VALUE); + } + return redactSensitiveText(scrubbed, { + broadKeys: true, + keepVariableReferences: true, + }); +}; + +export const scrubSecretText = ( + text: string, + matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, +) => { + if (text === "") { + return text; + } + const scrubbed = ( + matcher.pattern + ? text.replace(matcher.pattern, REDACTED_SECRET_VALUE) + : text + ).replace(PRIVATE_KEY_BLOCK, REDACTED_SECRET_VALUE); + if (!hasLongLine(scrubbed)) { + return scrubCredentialPatterns(scrubbed); + } + return scrubbed + .split("\n") + .map((line) => splitLongLine(line).map(scrubCredentialPatterns).join("")) + .join("\n"); +}; + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/; + +// Under a secret-bearing key every value is blanked. The names in a list of +// assignments (a docker `Env` array) and the keys of a map stay readable. +const blankSecretValue = (value: unknown, listItem = false): unknown => { + if (typeof value === "string") { + if (value === "") { + return value; + } + const assignment = listItem ? ENV_ASSIGNMENT.exec(value) : null; + return assignment + ? `${assignment[1]}=${REDACTED_SECRET_VALUE}` + : REDACTED_SECRET_VALUE; + } + if (Array.isArray(value)) { + return value.map((item) => blankSecretValue(item, true)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, blankSecretValue(item)]), + ); + } + return value; +}; + +const scrubValue = ( + value: unknown, + key: string | null, + matcher: KnownSecretMatcher, +): unknown => { + if (typeof value === "string") { + return key !== null && IDENTITY_KEY.test(key) + ? value + : scrubSecretText(value, matcher); + } + if (Array.isArray(value)) { + return value.map((item) => scrubValue(item, key, matcher)); + } + if (!isPlainObject(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([childKey, child]) => [ + childKey, + isSensitiveOutputKey(childKey) + ? blankSecretValue(child) + : scrubValue(child, childKey, matcher), + ]), + ); +}; + +export const scrubSecretOutput = ( + value: T, + matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, +): T => scrubValue(value, null, matcher) as T; From 167c16917fe2a4bf5c67b455a76588c0625cce8d Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:33:46 -0400 Subject: [PATCH 2/5] fix(security): keep image tags, ports and config structure readable for API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Широкий фильтр текста больше не принимает за «ключ: значение» то, что значением не является: порт после хоста (`auth-db:5432`), тег образа (`auth-api:1.2.3`, `:latest`), YAML-ключ с вложенным блоком (`auth-router:` и перенос строки), JSON-объект или массив после ключа. - Значение должно стоять на той же строке, что и ключ. - Поля со ссылкой на образ (`image`, `dockerImage`, `Image`) не переписываются, как идентификаторы и ревизии. Зачем: - Агент читает compose-файлы и конфиги Traefik, чтобы их менять. Если имя сервиса содержит слово вроде auth, session или token, прежний фильтр прятал порт, тег или следующую строку, и вернуть такой текст обратно было нельзя. Риски: - Секрет, записанный как `имя: значение`, где значение выглядит как номер порта (до 5 цифр), версия (`1.2.3`) или слово latest/stable/main/master/canary/edge/nightly, этим шаблоном не скрывается. Значения, известные панели, по-прежнему вырезаются отдельно. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run __test__/security/secret-scrub.test.ts __test__/security/api-key-output-guard.test.ts __test__/security/secret-redaction.test.ts` - 129 passed; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на живой панели не проверялось. What: - The broad text scrubber no longer reads things that are not a value as "key: value": a port after a host (`auth-db:5432`), an image tag (`auth-api:1.2.3`, `:latest`), a YAML key with a nested block (`auth-router:` and a line break), a JSON object or array after a key. - The value has to sit on the same line as the key. - Image reference fields (`image`, `dockerImage`, `Image`) are not rewritten, like identifiers and revisions. Why: - An agent reads compose files and Traefik configs in order to change them. When a service name contained a word such as auth, session or token, the previous scrubber hid the port, the tag or the next line, and such text could not be written back. Risks: - A secret written as `name: value` whose value looks like a port number (up to 5 digits), a version (`1.2.3`) or the word latest/stable/main/master/canary/edge/nightly is not hidden by this pattern. Values the panel knows are still removed separately. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run __test__/security/secret-scrub.test.ts __test__/security/api-key-output-guard.test.ts __test__/security/secret-redaction.test.ts` - 129 passed; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a live panel. Co-Authored-By: Claude Opus 5.5 --- .../__test__/security/secret-scrub.test.ts | 37 ++++++++++++++++++- .../server/src/utils/security/redaction.ts | 12 ++++-- .../server/src/utils/security/secret-scrub.ts | 8 ++-- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts index c2cc08ef7e4..81e3530a55f 100644 --- a/apps/dokploy/__test__/security/secret-scrub.test.ts +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -106,6 +106,15 @@ describe("scrubSecretOutput", () => { }); }); + it("never rewrites image references", () => { + const value = { + dockerImage: "auth-service:release-2", + Image: "registry.example.test/team/token-api:main", + }; + + expect(scrubSecretOutput(value)).toEqual(value); + }); + it("never rewrites identifiers and names", () => { const matcher = buildKnownSecretMatcher(["svc-auth-Prod1"]); const value = { @@ -225,8 +234,32 @@ describe("scrubSecretText", () => { "SMTP_HOST: mail.auth.example:587", "upstream token-service:8080 is healthy", "listening on port 3000, workers: 4", - ])("does not read a host and port as a key and value in %s", (input) => { - expect(scrubSecretText(input)).toBe(input); + "image: auth-service:1.2.3-alpine", + "image: session-store:latest", + 'ports:\n - "auth-proxy:8443"', + ])( + "does not read a host, port or image tag as a key and value in %s", + (input) => { + expect(scrubSecretText(input)).toBe(input); + }, + ); + + it("leaves the structure of YAML and JSON with secret-looking names", () => { + const yaml = [ + "http:", + " routers:", + " auth-service-router:", + " rule: Host(`app.example.test`)", + " service: auth-service", + "services:", + " session-store:", + " image: redis:7", + ].join("\n"); + const json = + '{\n "auth": {\n "user": "app"\n },\n "tokens": ["a"]\n}'; + + expect(scrubSecretText(yaml)).toBe(yaml); + expect(scrubSecretText(json)).toBe(json); }); it.each([ diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 777b232787c..7e3c61fe547 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -435,11 +435,15 @@ const BROAD_SENSITIVE_KEY_PATTERN = const BRACED_VALUE_PATTERN = String.raw`(?:\$\{\{[^}\s]*\}\}|\$\{[^}\s]*\})+`; const KEY_VALUE_PATTERN = `${BRACED_VALUE_PATTERN}|"[^"]*"|'[^']*'|[^\\s,}\\]]+`; +// What follows `name:` in an image reference or a host is a tag or a port. +const TAG_OR_PORT_PATTERN = String.raw`(?:\d{1,5}|v?\d+(?:\.\d+)+[\w.-]*|latest|stable|main|master|canary|edge|nightly)(?:[/\s"',)\]]|$)`; + // The broad patterns run over whole logs and files, so each one anchors on the // keyword and bounds what surrounds it: scanning a free prefix from every word // boundary is quadratic on a long line without spaces. A key also has to look -// like a key, or the short keywords would read `auth-db:5432` in a URL as the -// key `auth-db` with the value `5432`. +// like a key with a scalar value on the same line. Otherwise the short +// keywords would read `auth-db:5432` in a URL, the image `auth-api:1.2.3` or +// the YAML mapping `auth-router:` as a secret. const BROAD_KEY_PATTERNS: KeyPatterns = { beforeAuthorization: [ new RegExp( @@ -449,11 +453,11 @@ const BROAD_KEY_PATTERNS: KeyPatterns = { ], afterAuthorization: [ new RegExp( - `(${BROAD_SENSITIVE_KEY_PATTERN}[^"'\\n]{0,80}["']\\s*:\\s*)(${KEY_VALUE_PATTERN})`, + `(${BROAD_SENSITIVE_KEY_PATTERN}[^"'\\n]{0,80}["'][ \\t]*:[ \\t]*)(?![{[])(${KEY_VALUE_PATTERN})`, "gi", ), new RegExp( - `(?<=(? Date: Mon, 12 Oct 2026 14:23:10 -0400 Subject: [PATCH 3/5] feat(security): let API keys edit compose files and mounted files without seeing their secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - В compose-файле для API-ключа скрывается каждое буквальное значение в блоках environment и build args и каждое встроенное тело secrets/configs, как бы ни называлась переменная. Это правило по месту в файле, а не по догадке об имени. Ссылки на переменные (${VAR}, ${{project.VAR}}), пустые значения и остальная структура файла видны; учитываются якоря и merge-ключи YAML. - Скрытое значение заменяется меткой: плейсхолдер плюс идентификатор значения (HMAC с серверным секретом, 12 символов). Одинаковые значения дают одинаковую метку, по самой метке значение не восстановить. - compose.update и mounts.update по API-ключу возвращают значения на место меток из сохранённого текста. Агент читает файл, меняет образ, порты, добавляет и удаляет переменные и сохраняет, не видя значений. - Сохранение отклоняется, если метку перенесли туда, где значение стало бы читаемым (например, в image или container_name), если метка не выдавалась для этого файла или если в тексте плейсхолдер без метки. - Файлы монтирования получают такие же метки для найденных в них секретов. - В список известных значений попадают секреты, записанные прямо в compose-файлы, и варианты каждого известного значения в base64 (три выравнивания) и hex, поэтому они вырезаются из логов и другого текста. - Поиск известных значений идёт по префиксному дереву: время не растёт с числом секретов. Зачем: - Раньше секрет, записанный в compose-файл под нейтральным именем, фильтр не узнавал, а текст с вычищенными местами нельзя было сохранить обратно. Теперь значения в блоках переменных скрыты всегда, а правка файла работает целиком. Риски: - Значение вне блоков переменных (метка, аргумент команды) под нейтральным именем и неизвестное панели по-прежнему видно: его место не говорит о том, что это секрет. - Агент видит в блоках переменных только имена и ссылки, в том числе для несекретных значений вроде порта. - Compose-файл с ошибкой YAML показывается API-ключу как один плейсхолдер и не редактируется по меткам. - После восстановления значение записывается в двойных кавычках; для env и build args это равнозначно. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2867 passed, 3 failed по таймауту в backup-failure-output и server-setup при параллельной нагрузке, отдельный запуск этих двух файлов - 18 passed; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на живой панели не проверялось. What: - In a compose file read with an API key, every literal value in the environment and build args blocks and every inline secrets/configs body is hidden, whatever the variable is called. The rule goes by position in the file, not by a guess about the name. Variable references (${VAR}, ${{project.VAR}}), empty values and the rest of the file stay visible; YAML anchors and merge keys are followed. - A hidden value becomes a token: the placeholder plus an id of the value (an HMAC keyed with a server secret, 12 characters). Equal values get equal tokens, and the token alone does not give the value away. - compose.update and mounts.update called with an API key put the values back in place of the tokens from the stored text. An agent reads the file, changes the image or ports, adds and removes variables and saves it without seeing the values. - The save is rejected when a token was moved to where the value would become readable (for example into image or container_name), when the token was not issued for this file, or when the text holds a placeholder without a token. - Mounted files get the same tokens for the secrets found in them. - Secrets written inline in compose files join the known values, together with the base64 (three alignments) and hex forms of every known value, so they are removed from logs and other text. - Known values are matched through a prefix tree, so the time does not grow with the number of secrets. Why: - A secret written into a compose file under a neutral name was not recognised before, and text with scrubbed spots could not be saved back. Now values in the variable blocks are always hidden, and editing the file works end to end. Risks: - A value outside the variable blocks (a label, a command argument) under a neutral name and unknown to the panel is still visible: its position does not say it is a secret. - In the variable blocks an agent sees only names and references, also for non-secret values such as a port. - A compose file that is not valid YAML is shown to an API key as a single placeholder and cannot be edited through tokens. - A restored value is written in double quotes; for env and build args that is equivalent. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2867 passed, 3 timed out in backup-failure-output and server-setup under parallel load, those two files alone - 18 passed; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a live panel. Co-Authored-By: Claude Opus 5.5 --- .../security/api-key-output-guard.test.ts | 152 ++++++ .../__test__/security/compose-secrets.test.ts | 403 ++++++++++++++ .../__test__/security/known-secrets.test.ts | 15 +- .../__test__/security/secret-scrub.test.ts | 72 ++- apps/dokploy/server/api/routers/compose.ts | 11 +- apps/dokploy/server/api/routers/mount.ts | 11 +- apps/dokploy/server/api/trpc.ts | 2 +- .../server/api/utils/api-key-output-guard.ts | 72 ++- packages/server/src/services/known-secrets.ts | 11 +- .../src/utils/security/compose-secrets.ts | 502 ++++++++++++++++++ .../server/src/utils/security/redaction.ts | 65 ++- .../src/utils/security/secret-output.ts | 111 ++++ .../server/src/utils/security/secret-scrub.ts | 220 ++++---- 13 files changed, 1513 insertions(+), 134 deletions(-) create mode 100644 apps/dokploy/__test__/security/compose-secrets.test.ts create mode 100644 packages/server/src/utils/security/compose-secrets.ts create mode 100644 packages/server/src/utils/security/secret-output.ts diff --git a/apps/dokploy/__test__/security/api-key-output-guard.test.ts b/apps/dokploy/__test__/security/api-key-output-guard.test.ts index f1c73d3b34c..68a2381cfa0 100644 --- a/apps/dokploy/__test__/security/api-key-output-guard.test.ts +++ b/apps/dokploy/__test__/security/api-key-output-guard.test.ts @@ -51,7 +51,67 @@ const secretReader = protectedProcedure.mutation(() => { return { env: SENTINEL }; }); +const storedCompose = [ + "services:", + " api:", + " image: api:1", + " environment:", + ` FAVOURITE_COLOUR: ${SENTINEL}`, + " NEUTRAL_NAME: plain-neutral-value", + "", +].join("\n"); +const storedMount = `user=app\npassword=${SENTINEL}\nport=8080\n`; +const saved = vi.fn(); + +const { restoreApiKeyText } = await import( + "@/server/api/utils/api-key-output-guard" +); + const testRouter = createTRPCRouter({ + compose: createTRPCRouter({ + one: protectedProcedure.query(() => ({ + composeId: "compose-1", + composeFile: storedCompose, + })), + getConvertedCompose: protectedProcedure.query(() => storedCompose), + update: protectedProcedure + .input( + z.object({ + composeFile: z.string(), + description: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + saved( + await restoreApiKeyText( + ctx.session, + "compose", + input.composeFile, + storedCompose, + ), + ); + return true; + }), + }), + mounts: createTRPCRouter({ + one: protectedProcedure.query(() => ({ + mountId: "m-1", + content: storedMount, + })), + update: protectedProcedure + .input(z.object({ content: z.string() })) + .mutation(async ({ input, ctx }) => { + saved( + await restoreApiKeyText( + ctx.session, + "file", + input.content, + storedMount, + ), + ); + return true; + }), + }), application: createTRPCRouter({ one: protectedProcedure.query(() => service), readLogs: protectedProcedure.query( @@ -234,6 +294,98 @@ describe("procedures that return raw secrets", () => { }); }); +describe("texts an API key may edit", () => { + const TOKEN = /__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/g; + + it("shows a compose file with every literal value behind a token", async () => { + const { composeFile } = await apiKey().compose.one(); + const converted = await apiKey().compose.getConvertedCompose(); + + for (const text of [composeFile, converted]) { + expect(text).not.toContain(SENTINEL); + expect(text).not.toContain("plain-neutral-value"); + expect(text).toContain("image: api:1"); + expect(text.match(TOKEN)).toHaveLength(2); + } + }); + + it("puts the values back when the edited compose file is saved", async () => { + const { composeFile } = await apiKey().compose.one(); + + await apiKey().compose.update({ + composeFile: composeFile.replace("image: api:1", "image: api:2"), + }); + + expect(saved).toHaveBeenCalledTimes(1); + const restored = saved.mock.calls[0]?.[0] as string; + expect(restored).toContain("image: api:2"); + expect(restored).toContain(SENTINEL); + expect(restored).toContain("plain-neutral-value"); + expect(restored.match(TOKEN)).toBeNull(); + }); + + it("does the same for a mounted file", async () => { + const { content } = await apiKey().mounts.one(); + expect(content).not.toContain(SENTINEL); + + await apiKey().mounts.update({ + content: content.replace("port=8080", "port=9090"), + }); + + expect(saved).toHaveBeenCalledWith( + storedMount.replace("port=8080", "port=9090"), + ); + }); + + it("rejects a compose file whose hidden value was moved into view", async () => { + const { composeFile } = await apiKey().compose.one(); + // The second value is unknown to the panel: only its place hides it. + const token = composeFile.match(TOKEN)?.[1] as string; + + await expect( + apiKey().compose.update({ + composeFile: composeFile.replace("image: api:1", `image: ${token}`), + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(saved).not.toHaveBeenCalled(); + }); + + it("rejects tokens outside the field that can restore them", async () => { + const { composeFile } = await apiKey().compose.one(); + const token = composeFile.match(TOKEN)?.[0] as string; + + await expect( + apiKey().compose.update({ + composeFile: "services: {}", + description: token, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().application.update({ command: `run ${token}` }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(saved).not.toHaveBeenCalled(); + }); + + it("rejects placeholders that carry no token", async () => { + await expect( + apiKey().compose.update({ + composeFile: `services:\n a:\n image: ${R}\n`, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + apiKey().mounts.update({ content: "password=[REDACTED]" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("leaves what a browser session sends untouched", async () => { + const text = `services:\n a:\n image: ${R}\n`; + + await browser().compose.update({ composeFile: text }); + + expect(saved).toHaveBeenCalledWith(text); + }); +}); + describe("known secrets cache", () => { it("is dropped after every mutation, from an API key or a browser", async () => { await apiKey().application.update({ command: "run" }); diff --git a/apps/dokploy/__test__/security/compose-secrets.test.ts b/apps/dokploy/__test__/security/compose-secrets.test.ts new file mode 100644 index 00000000000..494363b5cbf --- /dev/null +++ b/apps/dokploy/__test__/security/compose-secrets.test.ts @@ -0,0 +1,403 @@ +import { + hideComposeSecrets, + restoreComposeSecrets, + restoreTextSecrets, + SecretRestoreError, +} from "@dokploy/server/utils/security/compose-secrets"; +import { REDACTED_SECRET_VALUE as BARE } from "@dokploy/server/utils/security/redaction"; +import { + buildKnownSecretMatcher, + EMPTY_KNOWN_SECRET_MATCHER, + findSecretTokens, + scrubSecretText, + secretTokenFor, +} from "@dokploy/server/utils/security/secret-scrub"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const NONE = EMPTY_KNOWN_SECRET_MATCHER; +const hide = (text: string, matcher = NONE) => + hideComposeSecrets(text, matcher); +const restore = (submitted: string, stored: string, matcher = NONE) => + restoreComposeSecrets(submitted, stored, matcher); + +const SECRETS = [ + "plain-neutral-value", + "quoted neutral: value", + "4242", + "list-neutral-value", + "quoted list value", + "build-arg-value", + "inline secret body", + "first line\nsecond line\n", + "shared-anchor-value", + "cli-flag-value", +]; + +const COMPOSE = `# deploy notes, password: comment-secret-1 +x-defaults: &defaults + SHARED_SETTING: shared-anchor-value +services: + api: + image: registry.example.test/team/api:1.4.2 + container_name: api + ports: + - "8080:80" + environment: + <<: *defaults + FAVOURITE_COLOUR: plain-neutral-value + GREETING: "quoted neutral: value" + PIN: 4242 + FROM_ENV: \${FROM_ENV} + FROM_PROJECT: \${{project.SHARED}} + EMPTY_ONE: + BANNER: | + first line + second line + command: server --listen 0.0.0.0 --api-token cli-flag-value # keep + labels: + - "traefik.http.routers.api.rule=Host(\`api.example.test\`)" + worker: + build: + context: . + args: + VARIANT: build-arg-value + environment: + - MODE=list-neutral-value + - "TITLE=quoted list value" + - PASSED_THROUGH + - REFERENCED=\${REFERENCED} +configs: + motd: + content: inline secret body +`; + +describe("hideComposeSecrets", () => { + const hidden = hide(COMPOSE); + + it("hides every literal value of the variable blocks, whatever the name", () => { + for (const secret of SECRETS) { + expect(hidden).not.toContain(secret.trim().split("\n")[0]); + } + expect(hidden).not.toContain("comment-secret-1"); + }); + + it("keeps names, references, empty values and the rest of the file", () => { + for (const kept of [ + "image: registry.example.test/team/api:1.4.2", + "container_name: api", + '- "8080:80"', + "<<: *defaults", + "FROM_ENV: ${FROM_ENV}", + "FROM_PROJECT: ${{project.SHARED}}", + "EMPTY_ONE:", + "- PASSED_THROUGH", + "- REFERENCED=${REFERENCED}", + "traefik.http.routers.api.rule=Host(`api.example.test`)", + "context: .", + "# keep", + ]) { + expect(hidden).toContain(kept); + } + for (const name of [ + "SHARED_SETTING", + "FAVOURITE_COLOUR", + "GREETING", + "PIN", + "BANNER", + "VARIANT", + "MODE=", + "TITLE=", + ]) { + expect(hidden).toContain(name); + } + }); + + it("stays valid YAML with the same structure", () => { + const before = parse(COMPOSE, { merge: true }); + const after = parse(hidden, { merge: true }); + + expect(Object.keys(after.services)).toEqual(Object.keys(before.services)); + expect(Object.keys(after.services.api.environment).sort()).toEqual( + Object.keys(before.services.api.environment).sort(), + ); + expect(after.services.api.image).toBe(before.services.api.image); + expect(after.services.worker.environment).toHaveLength(4); + }); + + it("gives the same value the same token and different values different ones", () => { + const text = + "services:\n a:\n environment:\n ONE: same-value-1\n TWO: same-value-1\n THREE: other-value-2\n"; + const tokens = findSecretTokens(hide(text)); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toBe(tokens[1]); + expect(tokens[0]).not.toBe(tokens[2]); + expect(tokens[0]).toBe(secretTokenFor("same-value-1")); + expect(tokens[0]).toMatch(/^__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__$/); + }); + + it("is stable when applied again", () => { + expect(hide(hidden)).toBe(hidden); + }); + + it("hides values the panel knows wherever they stand", () => { + const matcher = buildKnownSecretMatcher(["Known-Value-77"]); + const text = + "services:\n a:\n image: app\n labels:\n note: uses Known-Value-77 here\n healthcheck:\n test: curl -H 'X-Probe: Known-Value-77' localhost\n"; + + expect(hide(text, matcher)).not.toContain("Known-Value-77"); + }); + + it("shows nothing of a file that does not parse", () => { + expect(hide("services:\n a:\n environment:\n X: [unclosed\n")).toBe( + BARE, + ); + }); + + it("leaves an empty file alone", () => { + expect(hide("")).toBe(""); + expect(hide(" \n")).toBe(" \n"); + }); +}); + +describe("restoreComposeSecrets", () => { + const hidden = hide(COMPOSE); + + it("puts every value back when the file comes back untouched", () => { + const restored = restore(hidden, COMPOSE); + + expect(findSecretTokens(restored)).toEqual([]); + expect(parse(restored, { merge: true })).toEqual( + parse(COMPOSE, { merge: true }), + ); + expect(restored).toContain("comment-secret-1"); + }); + + it("returns a file without tokens as it is", () => { + const fresh = + "services:\n a:\n image: app:2\n environment:\n NEW: brand-new\n"; + + expect(restore(fresh, COMPOSE)).toBe(fresh); + }); + + it("keeps the hidden values through ordinary edits", () => { + const edited = hidden + .replace("api:1.4.2", "api:1.5.0") + .replace('- "8080:80"', '- "9090:80"') + .replace( + " EMPTY_ONE:\n", + " EMPTY_ONE:\n ADDED: fresh-literal\n", + ) + .replace(" - PASSED_THROUGH\n", ""); + const restored = parse(restore(edited, COMPOSE), { merge: true }); + const original = parse(COMPOSE, { merge: true }); + + expect(restored.services.api.image).toBe( + "registry.example.test/team/api:1.5.0", + ); + expect(restored.services.api.ports).toEqual(["9090:80"]); + expect(restored.services.api.environment).toEqual({ + ...original.services.api.environment, + ADDED: "fresh-literal", + }); + expect(restored.services.worker.environment).toEqual([ + "MODE=list-neutral-value", + "TITLE=quoted list value", + "REFERENCED=${REFERENCED}", + ]); + expect(restored.configs.motd.content).toBe("inline secret body"); + }); + + it("lets a value be removed, or moved into another variable", () => { + const colour = secretTokenFor("plain-neutral-value"); + const edited = hidden + .replace(` FAVOURITE_COLOUR: ${colour}\n`, "") + .replace(" - PASSED_THROUGH\n", ` - COLOUR=${colour}\n`); + const restored = parse(restore(edited, COMPOSE), { merge: true }); + + expect(restored.services.api.environment.FAVOURITE_COLOUR).toBeUndefined(); + expect(restored.services.worker.environment).toContain( + "COLOUR=plain-neutral-value", + ); + }); + + it("restores values that need quoting", () => { + const stored = `services:\n a:\n environment:\n TRICKY: 'it''s "quoted" # not a comment: yes'\n MULTI: "line one\\nline two"\n`; + const restored = parse(restore(hide(stored), stored)); + + expect(restored).toEqual(parse(stored)); + }); + + it.each([ + [ + "a placeholder without a token", + (text: string) => + text.replace(/__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, BARE), + ], + [ + "an MCP placeholder", + (text: string) => + text.replace(/__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, "[REDACTED]"), + ], + [ + "a token that was never issued", + (text: string) => + text.replace( + /__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + `${BARE}AAAAAAAAAAAA__`, + ), + ], + ["broken YAML", (text: string) => `${text}\n bad: [unclosed\n`], + ])("rejects %s", (_name, change) => { + expect(() => restore(change(hidden), COMPOSE)).toThrow(SecretRestoreError); + }); + + it.each([ + [ + "the image", + " image: registry.example.test/team/api:1.4.2", + " image: TOKEN", + ], + [ + "the container name", + " container_name: api", + " container_name: TOKEN", + ], + [ + "a label", + "traefik.http.routers.api.rule=Host(`api.example.test`)", + "note=TOKEN", + ], + ["a key", " container_name: api", " TOKEN: api"], + ])("refuses to move a hidden value into %s", (_name, from, to) => { + const token = secretTokenFor("plain-neutral-value"); + const moved = hidden.replace(from, to.replace("TOKEN", token)); + + expect(moved).not.toBe(hidden); + expect(() => restore(moved, COMPOSE)).toThrow(/would become readable/); + }); + + it("never reveals a value in its error messages", () => { + const moved = hidden.replace( + " container_name: api", + ` container_name: ${secretTokenFor("plain-neutral-value")}`, + ); + let message = ""; + try { + restore(moved, COMPOSE); + } catch (error) { + message = (error as Error).message; + } + + expect(message).not.toBe(""); + for (const secret of SECRETS) { + expect(message).not.toContain(secret); + } + }); + + it("survives random values in every style", () => { + const alphabet = "abcXYZ019 :#'\"\\{}[],&*!|>%@`=-_/\n\t$ёж"; + let seed = 20261012; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const randomValue = () => + Array.from( + { length: 1 + Math.floor(random() * 24) }, + () => alphabet[Math.floor(random() * alphabet.length)], + ).join(""); + + for (let round = 0; round < 300; round++) { + const values = Array.from({ length: 4 }, randomValue); + const stored = [ + "services:", + " app:", + " image: app:1", + " environment:", + ...values.map( + (value, index) => ` VAR_${index}: ${JSON.stringify(value)}`, + ), + " build:", + " args:", + ...values.map( + (value, index) => + ` - ${JSON.stringify(`ARG_${index}=${value}`)}`, + ), + "", + ].join("\n"); + const shown = hide(stored); + const restored = restore(shown, stored); + + expect(parse(restored), `round ${round}`).toEqual(parse(stored)); + for (const value of values) { + const literal = value.trim(); + if (literal.length >= 6 && !/^\$/.test(literal)) { + expect(shown.includes(JSON.stringify(value)), `round ${round}`).toBe( + false, + ); + } + } + } + }); +}); + +describe("restoreTextSecrets", () => { + const matcher = buildKnownSecretMatcher(["Known-Value-77"]); + const stored = [ + "server {", + ' proxy_set_header Authorization "Bearer upstream.token.value";', + " set $upstream_password hunter2-hunter2;", + " # db: postgres://app:Pa55-word@db:5432/app", + " set $marker Known-Value-77;", + " listen 8080;", + "}", + "", + ].join("\n"); + const shown = scrubSecretText(stored, matcher, secretTokenFor); + + it("hides the values behind tokens", () => { + for (const secret of [ + "upstream.token.value", + "Pa55-word", + "Known-Value-77", + ]) { + expect(shown).not.toContain(secret); + } + expect(shown).toContain("listen 8080;"); + expect(findSecretTokens(shown).length).toBeGreaterThanOrEqual(3); + }); + + it("puts them back exactly, also after an edit elsewhere", () => { + expect(restoreTextSecrets(shown, stored, matcher)).toBe(stored); + expect( + restoreTextSecrets( + shown.replace("listen 8080;", "listen 9090;"), + stored, + matcher, + ), + ).toBe(stored.replace("listen 8080;", "listen 9090;")); + }); + + it("refuses a value moved to where it would be readable", () => { + const token = secretTokenFor("Pa55-word"); + const moved = shown.replace("listen 8080;", `server_name ${token};`); + + expect(() => restoreTextSecrets(moved, stored, NONE)).toThrow( + SecretRestoreError, + ); + }); + + it("refuses placeholders that cannot be resolved", () => { + expect(() => restoreTextSecrets(`x ${BARE}`, stored, matcher)).toThrow( + SecretRestoreError, + ); + expect(() => restoreTextSecrets("x [REDACTED]", stored, matcher)).toThrow( + SecretRestoreError, + ); + expect(() => + restoreTextSecrets(`x ${BARE}AAAAAAAAAAAA__`, stored, matcher), + ).toThrow(SecretRestoreError); + }); +}); diff --git a/apps/dokploy/__test__/security/known-secrets.test.ts b/apps/dokploy/__test__/security/known-secrets.test.ts index 03bbc772f50..d086b659bd0 100644 --- a/apps/dokploy/__test__/security/known-secrets.test.ts +++ b/apps/dokploy/__test__/security/known-secrets.test.ts @@ -50,7 +50,18 @@ describe("getKnownSecretMatcher", () => { password: "App-Registry-Pass-1", }, ]); - state.rows.set(schema.compose, [{ env: "JWT_SECRET=Compose-Value-1" }]); + state.rows.set(schema.compose, [ + { + env: "JWT_SECRET=Compose-Value-1", + composeFile: + "services:\n api:\n environment:\n API_TOKEN: Compose-Inline-1\n - bad", + }, + { + env: null, + composeFile: + "services:\n api:\n environment:\n API_TOKEN: Compose-Inline-2\n COLOUR: teal\n build:\n args:\n - NPM_TOKEN=Compose-Inline-3\n", + }, + ]); state.rows.set(schema.postgres, [ { env: null, password: "Postgres-Pass-1" }, ]); @@ -79,6 +90,8 @@ describe("getKnownSecretMatcher", () => { "App-PreviewSecret-Value-1", "App-Registry-Pass-1", "Compose-Value-1", + "Compose-Inline-2", + "Compose-Inline-3", "Postgres-Pass-1", "Mysql-Pass-1", "Mysql-Root-1", diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts index 81e3530a55f..02584097dd4 100644 --- a/apps/dokploy/__test__/security/secret-scrub.test.ts +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -1,10 +1,12 @@ import { REDACTED_SECRET_VALUE as R } from "@dokploy/server/utils/security/redaction"; +import { + isSensitiveOutputKey, + scrubSecretOutput, +} from "@dokploy/server/utils/security/secret-output"; import { buildKnownSecretMatcher, collectEnvSecretValues, collectSecretValue, - isSensitiveOutputKey, - scrubSecretOutput, scrubSecretText, } from "@dokploy/server/utils/security/secret-scrub"; import { describe, expect, it } from "vitest"; @@ -141,24 +143,43 @@ describe("scrubSecretOutput", () => { }); }); - it("scrubs free text fields instead of blanking them", () => { + it("hides every literal of a compose file behind a token and scrubs commands", () => { const scrubbed = scrubSecretOutput({ composeFile: [ "services:", " api:", + " image: api:1.2.3", " environment:", " DATABASE_PASSWORD: hunter2-hunter2", " SESSION_SECRET: ${SESSION_SECRET}", - " PORT: 3000", + " FAVOURITE_COLOUR: teal", ].join("\n"), command: "server --api-token=abc123 --port 8080", }) as { composeFile: string; command: string }; - expect(scrubbed.composeFile).toContain(`DATABASE_PASSWORD: ${R}`); + expect(scrubbed.composeFile).toMatch( + /DATABASE_PASSWORD: __DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + ); + expect(scrubbed.composeFile).toMatch( + /FAVOURITE_COLOUR: __DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__/, + ); expect(scrubbed.composeFile).toContain("SESSION_SECRET: ${SESSION_SECRET}"); - expect(scrubbed.composeFile).toContain("PORT: 3000"); + expect(scrubbed.composeFile).toContain("image: api:1.2.3"); + expect(scrubbed.composeFile).not.toMatch(/hunter2|teal/); expect(scrubbed.command).toBe(`server --api-token=${R} --port 8080`); }); + + it("marks the hidden parts of a mounted file with tokens", () => { + const scrubbed = scrubSecretOutput({ + mounts: [ + { mountId: "m-1", content: "user=app\npassword=hunter2-hunter2\n" }, + ], + }) as { mounts: { content: string }[] }; + + expect(scrubbed.mounts[0]?.content).toMatch( + /^user=app\npassword=__DOKPLOY_REDACTED_SECRET__[A-Z2-7]{12}__\n$/, + ); + }); }); describe("scrubSecretText", () => { @@ -367,6 +388,45 @@ describe("known secret values", () => { ).toEqual(["p%40ss-W0rd", "p@ss-W0rd"].sort()); }); + it("also catches a known value printed in base64 or hex", () => { + const secret = "Zx9-sentinel-Value42"; + const matcher = buildKnownSecretMatcher([secret]); + const encodings = [ + Buffer.from(secret).toString("base64"), + Buffer.from(`user:${secret}`).toString("base64"), + Buffer.from(`ab${secret}\nNEXT=1`).toString("base64"), + Buffer.from(`abc${secret}tail`).toString("base64url"), + Buffer.from(secret).toString("hex"), + Buffer.from(secret).toString("hex").toUpperCase(), + ]; + + for (const encoded of encodings) { + const scrubbed = scrubSecretText(`dump ${encoded} end`, matcher); + expect(scrubbed, encoded).toContain(R); + expect(scrubbed, encoded).not.toContain(encoded.slice(6, 22)); + } + }); + + it("matches the longest of overlapping secrets among many", () => { + const secrets = Array.from( + { length: 400 }, + (_, index) => `Shared-Prefix-${index}-Tail9`, + ); + const matcher = buildKnownSecretMatcher([ + ...secrets, + "Shared-Prefix-7", + "Shared-Prefix-7-Tail9-Extended", + ]); + + expect(matcher.size).toBe(402); + expect( + scrubSecretText( + "a Shared-Prefix-7-Tail9-Extended b Shared-Prefix-399-Tail9 c Shared-Prefix-7 d", + matcher, + ), + ).toBe(`a ${R} b ${R} c ${R} d`); + }); + it("returns an empty matcher when nothing qualifies", () => { const matcher = buildKnownSecretMatcher(["short", "postgres"]); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 39d56f6ca38..ce93c44a712 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -101,6 +101,7 @@ import { import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { restoreApiKeyText } from "../utils/api-key-output-guard"; import { audit } from "../utils/audit"; import { assertDeploySourceCredentialAccess } from "../utils/deploy-source-access"; import { assertTargetEnvironmentAccess } from "../utils/placement-access"; @@ -402,7 +403,15 @@ export const composeRouter = createTRPCRouter({ }); const currentCompose = await findComposeById(input.composeId); const updateData = preserveSecretPlaceholderFields( - input, + { + ...input, + composeFile: await restoreApiKeyText( + ctx.session, + "compose", + input.composeFile, + currentCompose.composeFile, + ), + }, currentCompose, ["env", "composeFile", "customGitUrl"], ); diff --git a/apps/dokploy/server/api/routers/mount.ts b/apps/dokploy/server/api/routers/mount.ts index 9d9e0a06eb5..19f6e83d8a6 100644 --- a/apps/dokploy/server/api/routers/mount.ts +++ b/apps/dokploy/server/api/routers/mount.ts @@ -21,6 +21,7 @@ import { } from "@dokploy/server/services/permission"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { restoreApiKeyText } from "@/server/api/utils/api-key-output-guard"; import { audit } from "@/server/api/utils/audit"; import { apiCreateMount, @@ -159,7 +160,15 @@ export const mountRouter = createTRPCRouter({ resourceId: input.mountId, resourceName: input.mountPath, }); - return await updateMount(input.mountId, input); + return await updateMount(input.mountId, { + ...input, + content: await restoreApiKeyText( + ctx.session, + "file", + input.content, + mount.content, + ), + }); }), allNamedByApplicationId: protectedProcedure .input(z.object({ applicationId: z.string().min(1) })) diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index 60464e59cd6..47f89e04b6c 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -179,7 +179,7 @@ const apiKeyOutputGuard = t.middleware( if (viaApiKey) { assertProcedureAllowedForApiKey(path); if (type === "mutation") { - assertNoRedactedTextInInput(await getRawInput()); + assertNoRedactedTextInInput(path, await getRawInput()); } } const result = await next(); diff --git a/apps/dokploy/server/api/utils/api-key-output-guard.ts b/apps/dokploy/server/api/utils/api-key-output-guard.ts index bf8e5303721..2fe60e7104e 100644 --- a/apps/dokploy/server/api/utils/api-key-output-guard.ts +++ b/apps/dokploy/server/api/utils/api-key-output-guard.ts @@ -1,15 +1,24 @@ import { getKnownSecretMatcher } from "@dokploy/server/services/known-secrets"; +import { + hideComposeSecrets, + restoreComposeSecrets, + restoreTextSecrets, + SecretRestoreError, +} from "@dokploy/server/utils/security/compose-secrets"; import { isExactSecretPlaceholder, isSecretPlaceholderValue, } from "@dokploy/server/utils/security/redaction"; import { - EMPTY_KNOWN_SECRET_MATCHER, isSensitiveOutputKey, scrubSecretOutput, +} from "@dokploy/server/utils/security/secret-output"; +import { + EMPTY_KNOWN_SECRET_MATCHER, scrubSecretText, } from "@dokploy/server/utils/security/secret-scrub"; import { TRPCError } from "@trpc/server"; +import { isApiKeySession } from "@/server/api/utils/super-session"; // These hand back a stored secret or raw file bytes, which no scrubbing can // make safe (a container file may be nothing but one token), so an API key @@ -39,6 +48,20 @@ export const assertProcedureAllowedForApiKey = (path: string) => { } }; +// These procedures answer with a compose file as a bare string. +const COMPOSE_TEXT_PROCEDURES = new Set([ + "compose.getConvertedCompose", + "compose.isolatedDeployment", + "compose.randomizeCompose", +]); + +// The one field of each of these procedures that may carry tokens: the +// procedure puts the hidden values back from the stored text. +const RESTORED_INPUT_FIELDS: Record = { + "compose.update": "composeFile", + "mounts.update": "content", +}; + const findRedactedText = (value: unknown, key: string | null): boolean => { if (typeof value === "string") { if (!isSecretPlaceholderValue(value)) { @@ -64,12 +87,17 @@ const findRedactedText = (value: unknown, key: string | null): boolean => { // Text that was scrubbed on the way out must not come back in: saving it would // overwrite the real value with the placeholder. A whole secret field sent // back as the bare placeholder still means "keep what is stored". -export const assertNoRedactedTextInInput = (input: unknown) => { - if (findRedactedText(input, null)) { +export const assertNoRedactedTextInInput = (path: string, input: unknown) => { + const restoredField = RESTORED_INPUT_FIELDS[path]; + const checked = + restoredField && input !== null && typeof input === "object" + ? { ...input, [restoredField]: undefined } + : input; + if (findRedactedText(checked, null)) { throw new TRPCError({ code: "BAD_REQUEST", message: - "The input contains redacted text. Read values are scrubbed for API keys and cannot be written back; change environment variables with env.apply instead", + "The input contains redacted text. Hidden values can only be sent back as tokens in a compose file (compose.update) or a file mount (mounts.update); change environment variables with env.apply", }); } }; @@ -89,9 +117,39 @@ export const scrubApiKeyResult = async (path: string, data: unknown) => { if (API_KEY_UNSCRUBBED_PROCEDURES.has(path)) { return data; } - return isAsyncIterable(data) - ? scrubStream(data) - : scrubSecretOutput(data, await getKnownSecretMatcher()); + if (isAsyncIterable(data)) { + return scrubStream(data); + } + const matcher = await getKnownSecretMatcher(); + return typeof data === "string" && COMPOSE_TEXT_PROCEDURES.has(path) + ? hideComposeSecrets(data, matcher) + : scrubSecretOutput(data, matcher); +}; + +type RestoreSession = Parameters[0]; + +// An API key reads these texts with their secrets replaced by tokens. When it +// sends the text back, the values behind the tokens are put back from what is +// stored, so the caller can edit the rest without ever seeing them. +export const restoreApiKeyText = async ( + session: RestoreSession, + kind: "compose" | "file", + submitted: T, + stored: string | null | undefined, +): Promise => { + if (!isApiKeySession(session) || typeof submitted !== "string") { + return submitted; + } + const restore = + kind === "compose" ? restoreComposeSecrets : restoreTextSecrets; + try { + return restore(submitted, stored, await getKnownSecretMatcher()); + } catch (error) { + if (error instanceof SecretRestoreError) { + throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); + } + throw error; + } }; export const scrubApiKeyError = async (error: TRPCError) => { diff --git a/packages/server/src/services/known-secrets.ts b/packages/server/src/services/known-secrets.ts index c4d68146d42..5108174ed74 100644 --- a/packages/server/src/services/known-secrets.ts +++ b/packages/server/src/services/known-secrets.ts @@ -12,6 +12,7 @@ import { redis, registry, } from "@dokploy/server/db/schema"; +import { collectComposeSecretValues } from "@dokploy/server/utils/security/compose-secrets"; import { buildKnownSecretMatcher, collectEnvSecretValues, @@ -51,7 +52,9 @@ const loadKnownSecretValues = async () => { password: applications.password, }) .from(applications), - db.select({ env: compose.env }).from(compose), + db + .select({ env: compose.env, composeFile: compose.composeFile }) + .from(compose), db .select({ env: postgres.env, password: postgres.databasePassword }) .from(postgres), @@ -102,7 +105,11 @@ const loadKnownSecretValues = async () => { ); addPassword(row.password); } - for (const row of [...composeRows, ...projectRows, ...environmentRows]) { + for (const row of composeRows) { + addEnv(row.env); + values.push(...collectComposeSecretValues(row.composeFile)); + } + for (const row of [...projectRows, ...environmentRows]) { addEnv(row.env); } for (const row of [ diff --git a/packages/server/src/utils/security/compose-secrets.ts b/packages/server/src/utils/security/compose-secrets.ts new file mode 100644 index 00000000000..921af56aecc --- /dev/null +++ b/packages/server/src/utils/security/compose-secrets.ts @@ -0,0 +1,502 @@ +import { + type Document, + isAlias, + isMap, + isScalar, + isSeq, + type Node, + parseDocument, + type Scalar, + visit, + type YAMLMap, +} from "yaml"; +import { + isSecretLookingName, + isVariableReference, + REDACTED_SECRET_VALUE, +} from "./redaction"; +import { + collectSecretValue, + findSecretTokens, + hasUnrestorablePlaceholder, + type KnownSecretMatcher, + replaceSecretTokens, + scrubSecretText, + secretTokenFor, +} from "./secret-scrub"; + +/** + * A compose file is the one text an agent has to read whole and send back + * edited, so its secrets are hidden by position rather than by guessing: every + * literal value in an `environment` or build `args` block and every inline + * secret or config body is replaced, whatever the variable is called. Each + * hidden value becomes a token, and a file sent back with its tokens gets the + * values put back. + */ + +type SecretReplacement = (secret: string) => string; +type Edit = { start: number; end: number; text: string }; +type SlotKind = "value" | "assignment"; + +const PLAIN_SAFE = /^[A-Za-z0-9_./:@=,+-]+$/; + +const isHidden = (value: string) => value.includes(REDACTED_SECRET_VALUE); + +const isLiteral = (value: string) => + value !== "" && !isVariableReference(value) && !isHidden(value); + +const resolveNode = (document: Document, node: unknown): Node | undefined => { + if (isAlias(node)) { + return node.resolve(document); + } + return node === null || node === undefined ? undefined : (node as Node); +}; + +// A key may come from a merged anchor (`<<: *defaults`), so each map is looked +// up together with everything merged into it. +const findInMap = ( + document: Document, + map: YAMLMap, + key: string, + seen = new Set(), +): Node[] => { + if (seen.has(map)) { + return []; + } + seen.add(map); + const found: Node[] = []; + for (const pair of map.items) { + const name = isScalar(pair.key) ? pair.key.value : undefined; + if (name === key) { + const value = resolveNode(document, pair.value); + if (value) { + found.push(value); + } + } else if (name === "<<") { + const merged = resolveNode(document, pair.value); + const sources = isSeq(merged) ? merged.items : [merged]; + for (const source of sources) { + const target = resolveNode(document, source); + if (isMap(target)) { + found.push(...findInMap(document, target, key, seen)); + } + } + } + } + return found; +}; + +const collectVariableBlock = ( + document: Document, + block: Node | undefined, + slots: Map, + seen = new Set(), +) => { + if (!block || seen.has(block)) { + return; + } + seen.add(block); + if (isMap(block)) { + for (const pair of block.items) { + if (isScalar(pair.key) && pair.key.value === "<<") { + const merged = resolveNode(document, pair.value); + for (const source of isSeq(merged) ? merged.items : [merged]) { + collectVariableBlock( + document, + resolveNode(document, source), + slots, + seen, + ); + } + continue; + } + const value = resolveNode(document, pair.value); + if (isScalar(value)) { + slots.set(value, "value"); + } + } + } else if (isSeq(block)) { + for (const item of block.items) { + const value = resolveNode(document, item); + if (isScalar(value)) { + slots.set(value, "assignment"); + } + } + } +}; + +const collectSlots = (document: Document) => { + const slots = new Map(); + const root = document.contents; + if (!isMap(root)) { + return slots; + } + + for (const services of findInMap(document, root, "services")) { + if (!isMap(services)) { + continue; + } + for (const pair of services.items) { + const service = resolveNode(document, pair.value); + if (!isMap(service)) { + continue; + } + for (const block of findInMap(document, service, "environment")) { + collectVariableBlock(document, block, slots); + } + for (const build of findInMap(document, service, "build")) { + if (isMap(build)) { + for (const block of findInMap(document, build, "args")) { + collectVariableBlock(document, block, slots); + } + } + } + } + } + + for (const section of ["secrets", "configs"]) { + for (const entries of findInMap(document, root, section)) { + if (!isMap(entries)) { + continue; + } + for (const pair of entries.items) { + const entry = resolveNode(document, pair.value); + if (!isMap(entry)) { + continue; + } + for (const content of findInMap(document, entry, "content")) { + if (isScalar(content)) { + slots.set(content, "value"); + } + } + } + } + } + + return slots; +}; + +const formatScalar = (node: Scalar, text: string) => { + if (node.type === "PLAIN" && PLAIN_SAFE.test(text)) { + return text; + } + const quoted = JSON.stringify(text); + // A block scalar ends with its line break, a quoted one does not. + return node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED" + ? `${quoted}\n` + : quoted; +}; + +const applyEdits = (text: string, edits: Edit[]) => { + let result = text; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`; + } + return result; +}; + +const parseCompose = (text: string) => { + const document = parseDocument(text, { merge: false, uniqueKeys: false }); + return document.errors.length > 0 ? null : document; +}; + +type ScalarVisit = { + node: Scalar; + position: "key" | "value" | "item"; + parentKey: string | null; +}; + +const visitScalars = ( + document: Document, + visitor: (scalar: ScalarVisit & { range: [number, number] }) => void, +) => { + const ranges: [number, number][] = []; + visit(document, { + Scalar(key, node, path) { + if (!node.range) { + return; + } + const range: [number, number] = [node.range[0], node.range[1]]; + ranges.push(range); + const parent = path[path.length - 1]; + const pairKey = + key === "value" && parent && "key" in parent && isScalar(parent.key) + ? String(parent.key.value) + : null; + visitor({ + node, + range, + position: key === "key" ? "key" : key === "value" ? "value" : "item", + parentKey: pairKey, + }); + }, + }); + return ranges; +}; + +// Whatever lies outside the scalars is structure and comments, so a `#` there +// always starts a comment. +const commentRanges = (text: string, scalarRanges: [number, number][]) => { + const comments: [number, number][] = []; + const boundaries: [number, number][] = [ + ...[...scalarRanges].sort((a, b) => a[0] - b[0]), + [text.length, text.length], + ]; + let cursor = 0; + for (const [start, end] of boundaries) { + if (start > cursor) { + const gap = text.slice(cursor, start); + for (const match of gap.matchAll(/#[^\n]*/g)) { + comments.push([ + cursor + match.index, + cursor + match.index + match[0].length, + ]); + } + } + cursor = Math.max(cursor, end); + } + return comments; +}; + +const hideScalar = ( + { node, position, parentKey }: ScalarVisit, + slot: SlotKind | undefined, + matcher: KnownSecretMatcher, + replacement: SecretReplacement, +): string | null => { + const value = node.value; + if (value === null || value === undefined || typeof value === "boolean") { + return null; + } + const text = String(value); + + if (slot === "assignment") { + const separator = text.indexOf("="); + const literal = separator === -1 ? "" : text.slice(separator + 1); + return isLiteral(literal) + ? `${text.slice(0, separator + 1)}${replacement(literal)}` + : null; + } + if ( + slot === "value" || + (position === "value" && + parentKey !== null && + isSecretLookingName(parentKey)) + ) { + return isLiteral(text) ? replacement(text) : null; + } + if (typeof value !== "string") { + return null; + } + const scrubbed = scrubSecretText(text, matcher, replacement); + return scrubbed === text ? null : scrubbed; +}; + +export const hideComposeSecrets = ( + text: string, + matcher: KnownSecretMatcher, + replacement: SecretReplacement = secretTokenFor, +) => { + if (text.trim() === "") { + return text; + } + const document = parseCompose(text); + // A file that does not parse has no positions to go by, and what cannot be + // placed cannot be shown. + if (!document) { + return REDACTED_SECRET_VALUE; + } + + const slots = collectSlots(document); + const edits: Edit[] = []; + const scalarRanges = visitScalars(document, (scalar) => { + const hidden = hideScalar( + scalar, + slots.get(scalar.node), + matcher, + replacement, + ); + if (hidden !== null) { + edits.push({ + start: scalar.range[0], + end: scalar.range[1], + text: formatScalar(scalar.node, hidden), + }); + } + }); + for (const [start, end] of commentRanges(text, scalarRanges)) { + const comment = text.slice(start, end); + const scrubbed = scrubSecretText(comment, matcher, replacement); + if (scrubbed !== comment) { + edits.push({ start, end, text: scrubbed }); + } + } + return applyEdits(text, edits); +}; + +// A secret written into the compose file itself is as real as one stored in +// env, so it joins the values scrubbed out of logs and other text. +export const collectComposeSecretValues = ( + text: string | null | undefined, +): string[] => { + const document = text?.trim() ? parseCompose(text) : null; + if (!document) { + return []; + } + const slots = collectSlots(document); + const values: string[] = []; + visitScalars(document, ({ node, parentKey }) => { + const slot = slots.get(node); + if (!slot || node.value === null || node.value === undefined) { + return; + } + const text = String(node.value); + const separator = text.indexOf("="); + if (slot === "value") { + values.push(...collectSecretValue(parentKey ?? "", text)); + } else if (separator !== -1) { + values.push( + ...collectSecretValue( + text.slice(0, separator), + text.slice(separator + 1), + ), + ); + } + }); + return values; +}; + +export class SecretRestoreError extends Error {} + +const countTokens = (text: string) => { + const counts = new Map(); + for (const token of findSecretTokens(text)) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + return counts; +}; + +// A value may only come back where it is hidden again. Otherwise moving a +// token next to `image:` would turn the secret into readable text on the next +// read. +const assertStillHidden = (submitted: string, rehidden: string) => { + const visible = countTokens(rehidden); + for (const [token, count] of countTokens(submitted)) { + if ((visible.get(token) ?? 0) < count) { + throw new SecretRestoreError( + "A hidden value was moved to a place where it would become readable. Keep tokens inside environment variables, build args or the line they came from", + ); + } + } +}; + +const buildVault = ( + hide: (replacement: SecretReplacement) => string, +): Map => { + const vault = new Map(); + hide((secret) => { + const token = secretTokenFor(secret); + vault.set(token, secret); + return token; + }); + return vault; +}; + +const resolveToken = (vault: Map) => (token: string) => { + const value = vault.get(token); + if (value === undefined) { + throw new SecretRestoreError( + "The text contains a token that does not belong to the stored version. Read it again and keep its tokens unchanged", + ); + } + return value; +}; + +const assertRestorable = (submitted: string) => { + if (hasUnrestorablePlaceholder(submitted)) { + throw new SecretRestoreError( + "The text contains a redacted placeholder without a token, so the hidden value cannot be put back. Replace it with a variable reference or a new value", + ); + } +}; + +/** + * Puts the stored values back where a compose file sent by an API key carries + * their tokens. + */ +export const restoreComposeSecrets = ( + submitted: string, + stored: string | null | undefined, + matcher: KnownSecretMatcher, +) => { + assertRestorable(submitted); + if (findSecretTokens(submitted).length === 0) { + return submitted; + } + const document = parseCompose(submitted); + if (!document) { + throw new SecretRestoreError( + "The compose file is not valid YAML, so its hidden values cannot be put back", + ); + } + + const resolve = resolveToken( + buildVault((replacement) => + hideComposeSecrets(stored ?? "", matcher, replacement), + ), + ); + const edits: Edit[] = []; + const scalarRanges = visitScalars(document, ({ node, range }) => { + if ( + typeof node.value === "string" && + findSecretTokens(node.value).length > 0 + ) { + edits.push({ + start: range[0], + end: range[1], + text: formatScalar(node, replaceSecretTokens(node.value, resolve)), + }); + } + }); + for (const [start, end] of commentRanges(submitted, scalarRanges)) { + const comment = submitted.slice(start, end); + if (findSecretTokens(comment).length > 0) { + edits.push({ start, end, text: replaceSecretTokens(comment, resolve) }); + } + } + + const restored = applyEdits(submitted, edits); + if (findSecretTokens(restored).length > 0) { + throw new SecretRestoreError( + "A token stands where a value cannot be placed. Keep tokens inside values", + ); + } + assertStillHidden(submitted, hideComposeSecrets(restored, matcher)); + return restored; +}; + +/** + * The same for a file whose format is not known (a mounted config file): the + * value goes back exactly where its token stands. + */ +export const restoreTextSecrets = ( + submitted: string, + stored: string | null | undefined, + matcher: KnownSecretMatcher, +) => { + assertRestorable(submitted); + if (findSecretTokens(submitted).length === 0) { + return submitted; + } + const resolve = resolveToken( + buildVault((replacement) => + scrubSecretText(stored ?? "", matcher, replacement), + ), + ); + const restored = replaceSecretTokens(submitted, resolve); + assertStillHidden( + submitted, + scrubSecretText(restored, matcher, secretTokenFor), + ); + return restored; +}; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 7e3c61fe547..68fce1e1028 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -467,6 +467,11 @@ const BROAD_KEY_PATTERNS: KeyPatterns = { ], }; +const SECRET_LOOKING_NAME = new RegExp(BROAD_SENSITIVE_KEY_PATTERN, "i"); + +export const isSecretLookingName = (name: string) => + SECRET_LOOKING_NAME.test(name); + const VARIABLE_REFERENCE_PATTERN = String.raw`\$\{\{\s*[A-Za-z_][\w.-]*\s*\}\}|\$\{[A-Za-z_]\w*(?::?\?[^}]*)?\}|\$[A-Za-z_]\w*`; const VARIABLE_REFERENCE_VALUE = new RegExp( `^(?:${VARIABLE_REFERENCE_PATTERN})+$`, @@ -482,9 +487,19 @@ export const isVariableReference = (value: string) => { const isVariableReferenceUserinfo = (userinfo: string) => userinfo.split(":").every((part) => isVariableReference(part)); +// A restorable placeholder: the plain placeholder followed by an id of the +// hidden value. The id lets the same caller send the text back and have the +// value put back where the token stands. +export const SECRET_TOKEN_PATTERN = `${REDACTED_SECRET_VALUE}[A-Z2-7]{12}__`; +const ALREADY_HIDDEN_VALUE = new RegExp( + `^["']?${REDACTED_SECRET_VALUE}(?:[A-Z2-7]{12}__)?["']?$`, +); + export type RedactSensitiveTextOptions = { keepVariableReferences?: boolean; broadKeys?: boolean; + // Produces what stands in for a secret; defaults to the plain placeholder. + replacement?: (secret: string) => string; }; export function redactSensitiveText( @@ -519,35 +534,46 @@ export function redactSensitiveText( return value; } + const { replacement, keepVariableReferences } = options; const keyPatterns = options.broadKeys ? BROAD_KEY_PATTERNS : DEFAULT_KEY_PATTERNS; - const redactValue = (match: string, prefix: string, secret: string) => - options.keepVariableReferences && isVariableReference(secret) + // A token names its value, so a hidden value met again has to stay as it is: + // a token of a token could not be resolved. + const hide = (secret: string) => { + if (!replacement) { + return REDACTED_SECRET_VALUE; + } + return ALREADY_HIDDEN_VALUE.test(secret) ? secret : replacement(secret); + }; + const hideValue = (match: string, prefix: string, secret: string) => + keepVariableReferences && isVariableReference(secret) ? match - : `${prefix}${REDACTED_SECRET_VALUE}`; + : `${prefix}${hide(secret)}`; + const hideRest = (match: string, prefix: string) => + `${prefix}${hide(match.slice(prefix.length))}`; let redacted = value; redacted = redacted.replace( /(\b[a-z][a-z0-9+.-]{0,30}:\/\/)([^@\s/?#]+)@/gi, (match, scheme: string, userinfo: string) => - options.keepVariableReferences && isVariableReferenceUserinfo(userinfo) + keepVariableReferences && isVariableReferenceUserinfo(userinfo) ? match - : `${scheme}${REDACTED_SECRET_VALUE}@`, + : `${scheme}${hide(userinfo)}@`, ); for (const pattern of keyPatterns.beforeAuthorization) { - redacted = redacted.replace(pattern, redactValue); + redacted = redacted.replace(pattern, hideValue); } redacted = redacted.replace( /(\bAuthorization\s*:\s*)(?:Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, - `$1${REDACTED_SECRET_VALUE}`, + hideRest, ); for (const pattern of keyPatterns.afterAuthorization) { - redacted = redacted.replace(pattern, redactValue); + redacted = redacted.replace(pattern, hideValue); } // mysql/mariadb clients take the password glued to -p. A separate word after @@ -558,46 +584,43 @@ export function redactSensitiveText( String.raw`(\b(?:mysql|mariadb)[a-z-]*(?:\\.|[^\n;&|\\])*?\s-p)(${SHELL_VALUE_PATTERN})`, "g", ), - `$1${REDACTED_SECRET_VALUE}`, + hideRest, ); redacted = redacted.replace( /(\b(? - options.keepVariableReferences && isVariableReference(password) + keepVariableReferences && isVariableReference(password) ? match - : `${prefix}${REDACTED_SECRET_VALUE}${at}`, + : `${prefix}${hide(password)}${at}`, ); redacted = redacted.replace( - /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, - `$1 ${REDACTED_SECRET_VALUE}`, + /\b(Bearer|Basic)\s+([A-Za-z0-9._~+/-]+=*)/gi, + (_match, scheme: string, token: string) => `${scheme} ${hide(token)}`, ); - redacted = redacted.replace( - /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, - REDACTED_SECRET_VALUE, - ); + redacted = redacted.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, hide); return redacted; } diff --git a/packages/server/src/utils/security/secret-output.ts b/packages/server/src/utils/security/secret-output.ts new file mode 100644 index 00000000000..bed2956be9d --- /dev/null +++ b/packages/server/src/utils/security/secret-output.ts @@ -0,0 +1,111 @@ +import { hideComposeSecrets } from "./compose-secrets"; +import { REDACTED_SECRET_VALUE } from "./redaction"; +import { + EMPTY_KNOWN_SECRET_MATCHER, + type KnownSecretMatcher, + scrubSecretText, + secretTokenFor, +} from "./secret-scrub"; + +/** + * An API key response never carries secret values: fields that hold secrets + * are blanked, compose files keep their structure with every value hidden + * behind a restorable token, and every other string (logs, mounted files, + * commands) is scrubbed as text. + */ + +const SENSITIVE_OUTPUT_KEY = + /(?:env$|buildargs$|buildsecrets$|password|passwd|passphrase|secret|tokens?$|private_?key|api_?key$|access_?key|credential|authorization|^cookie$|dsn$|connection_?string|webhook_?url$|^dockerauth$|license_?key$|^userkey$|^headers$)/i; + +// `secretName`, `apiKeyId` or `tokenType` describe a secret without holding it. +const DESCRIPTIVE_KEY_SUFFIX = + /(?:Id|Ids|Name|Names|Path|Type|Count|At|Enabled|Status|Length|Prefix)$/; + +// Identifiers, revisions and image references are never secrets, and a caller +// that gets one back mangled can no longer address the resource or pass the +// revision on. +const IDENTITY_KEY = + /(?:^id$|Id$|Ids$|^name$|Name$|^revision$|Revision$|^image$|Image$)/; + +export const isSensitiveOutputKey = (key: string) => + SENSITIVE_OUTPUT_KEY.test(key) && !DESCRIPTIVE_KEY_SUFFIX.test(key); + +// Text the caller may edit and send back: its hidden values carry a token, so +// the panel can put them back on the matching write. +const COMPOSE_TEXT_KEY = /^composeFile$/; +const FILE_TEXT_KEY = /^content$/; + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/; + +// Under a secret-bearing key every value is blanked. The names in a list of +// assignments (a docker `Env` array) and the keys of a map stay readable. +const blankSecretValue = (value: unknown, listItem = false): unknown => { + if (typeof value === "string") { + if (value === "") { + return value; + } + const assignment = listItem ? ENV_ASSIGNMENT.exec(value) : null; + return assignment + ? `${assignment[1]}=${REDACTED_SECRET_VALUE}` + : REDACTED_SECRET_VALUE; + } + if (Array.isArray(value)) { + return value.map((item) => blankSecretValue(item, true)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, blankSecretValue(item)]), + ); + } + return value; +}; + +const scrubValue = ( + value: unknown, + key: string | null, + matcher: KnownSecretMatcher, +): unknown => { + if (typeof value === "string") { + if (key === null) { + return scrubSecretText(value, matcher); + } + if (IDENTITY_KEY.test(key)) { + return value; + } + if (COMPOSE_TEXT_KEY.test(key)) { + return hideComposeSecrets(value, matcher, secretTokenFor); + } + return scrubSecretText( + value, + matcher, + FILE_TEXT_KEY.test(key) ? secretTokenFor : undefined, + ); + } + if (Array.isArray(value)) { + return value.map((item) => scrubValue(item, key, matcher)); + } + if (!isPlainObject(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([childKey, child]) => [ + childKey, + isSensitiveOutputKey(childKey) + ? blankSecretValue(child) + : scrubValue(child, childKey, matcher), + ]), + ); +}; + +export const scrubSecretOutput = ( + value: T, + matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, +): T => scrubValue(value, null, matcher) as T; diff --git a/packages/server/src/utils/security/secret-scrub.ts b/packages/server/src/utils/security/secret-scrub.ts index db9c20de641..99683a6cb23 100644 --- a/packages/server/src/utils/security/secret-scrub.ts +++ b/packages/server/src/utils/security/secret-scrub.ts @@ -1,16 +1,17 @@ +import { createHmac } from "node:crypto"; import { parse } from "dotenv"; +import { betterAuthSecret } from "../../lib/auth-secret"; import { isVariableReference, REDACTED_SECRET_VALUE, redactSensitiveText, + SECRET_TOKEN_PATTERN, } from "./redaction"; /** - * What leaves the panel towards an AI (an API key response, a prompt for the - * AI provider) never carries secret values: fields that hold secrets are - * blanked, and every other string (logs, compose files, mounted files, - * commands) is scrubbed of known secret values and of text that looks like a - * credential. + * Text that leaves the panel towards an AI (an API key response, a prompt for + * the AI provider) is scrubbed of the secret values the panel stores and of + * anything that looks like a credential. */ export type KnownSecretMatcher = { @@ -23,19 +24,6 @@ export const EMPTY_KNOWN_SECRET_MATCHER: KnownSecretMatcher = { pattern: null, }; -const SENSITIVE_OUTPUT_KEY = - /(?:env$|buildargs$|buildsecrets$|password|passwd|passphrase|secret|tokens?$|private_?key|api_?key$|access_?key|credential|authorization|^cookie$|dsn$|connection_?string|webhook_?url$|^dockerauth$|license_?key$|^userkey$|^headers$)/i; - -// `secretName`, `apiKeyId` or `tokenType` describe a secret without holding it. -const DESCRIPTIVE_KEY_SUFFIX = - /(?:Id|Ids|Name|Names|Path|Type|Count|At|Enabled|Status|Length|Prefix)$/; - -// Identifiers, revisions and image references are never secrets, and a caller -// that gets one back mangled can no longer address the resource or pass the -// revision on. -const IDENTITY_KEY = - /(?:^id$|Id$|Ids$|^name$|Name$|^revision$|Revision$|^image$|Image$)/; - const SECRET_ENV_NAME = /(?:^|_)(?:AUTH|CERT|CONNECTION_STRING|COOKIE|CREDENTIALS?|DSN|ENCRYPTION|JWT|KEYS?|LICENSE|MNEMONIC|PASS|PASSPHRASE|PASSWD|PASSWORD|PRIVATE|PW|PWD|SALT|SECRETS?|SEED|SESSION|SIGNATURE|TOKENS?|WEBHOOK)(?:$|_)/i; @@ -43,9 +31,6 @@ const MIN_KNOWN_SECRET_LENGTH = 8; const OPAQUE_VALUE = /^[A-Za-z0-9+/_=-]{32,}$/; const URL_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/([^@\s/?#]+)@/i; -export const isSensitiveOutputKey = (key: string) => - SENSITIVE_OUTPUT_KEY.test(key) && !DESCRIPTIVE_KEY_SUFFIX.test(key); - // A value only goes on the scrub list when replacing it everywhere cannot // mangle ordinary output: dictionary-like words and plain numbers stay out. const isDistinctiveValue = (value: string) => @@ -101,6 +86,62 @@ export const collectEnvSecretValues = ( const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +type TrieNode = { children: Map; terminal: boolean }; + +// One alternation per secret makes the regex engine try every secret at every +// position. A prefix tree keeps a scan of a large log proportional to its size +// however many secrets the panel stores, and prefers the longest secret. +const toTriePattern = (node: TrieNode): string => { + const branches = [...node.children].map( + ([character, child]) => `${escapeRegExp(character)}${toTriePattern(child)}`, + ); + if (branches.length === 0) { + return ""; + } + if (node.terminal) { + return `(?:${branches.join("|")})?`; + } + return branches.length === 1 ? branches[0]! : `(?:${branches.join("|")})`; +}; + +const buildTriePattern = (values: Iterable) => { + const root: TrieNode = { children: new Map(), terminal: false }; + for (const value of values) { + let node = root; + for (const character of value) { + let child = node.children.get(character); + if (!child) { + child = { children: new Map(), terminal: false }; + node.children.set(character, child); + } + node = child; + } + node.terminal = true; + } + root.terminal = false; + return toTriePattern(root); +}; + +const MIN_ENCODED_FORM_LENGTH = 16; + +// A secret printed in base64 or hex is still the secret. Base64 text depends on +// where the value starts inside the encoded data, so each of the three +// alignments is covered, without the characters that mix in neighbouring bytes. +const encodedForms = (value: string) => { + const bytes = Buffer.from(value, "utf8"); + const hex = bytes.toString("hex"); + const forms = [hex, hex.toUpperCase()]; + for (let shift = 0; shift < 3; shift++) { + const encoded = Buffer.concat([Buffer.alloc(shift), bytes]) + .toString("base64") + .replace(/=+$/, ""); + const dangling = ((shift + bytes.length) * 8) % 6 === 0 ? 0 : 1; + const core = encoded.slice([0, 2, 3][shift], encoded.length - dangling); + forms.push(core, core.replace(/\+/g, "-").replace(/\//g, "_")); + } + return forms.filter((form) => form.length >= MIN_ENCODED_FORM_LENGTH); +}; + export const buildKnownSecretMatcher = ( values: Iterable, ): KnownSecretMatcher => { @@ -108,14 +149,54 @@ export const buildKnownSecretMatcher = ( if (unique.length === 0) { return EMPTY_KNOWN_SECRET_MATCHER; } - // Longest first, so a secret that contains another one is replaced whole. - unique.sort((a, b) => b.length - a.length); return { size: unique.length, - pattern: new RegExp(unique.map(escapeRegExp).join("|"), "g"), + pattern: new RegExp( + buildTriePattern([...unique, ...unique.flatMap(encodedForms)]), + "g", + ), }; }; +const TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +// A token is the placeholder plus an id of the hidden value. The id is keyed +// with a server secret, so it says nothing about the value, and it is the same +// each time the value is hidden, so a token read earlier still resolves. +export const secretTokenFor = (value: string) => { + const digest = createHmac("sha256", betterAuthSecret) + .update("secret-token-v1\0") + .update(value) + .digest(); + let id = ""; + for (let index = 0; index < 12; index++) { + id += TOKEN_ALPHABET[digest[index]! & 31]; + } + return `${REDACTED_SECRET_VALUE}${id}__`; +}; + +const SECRET_TOKEN = new RegExp(SECRET_TOKEN_PATTERN, "g"); +const WHOLE_SECRET_TOKEN = new RegExp(`^${SECRET_TOKEN_PATTERN}$`); +// The token comes first: a token starts with the plain placeholder. +const ANY_PLACEHOLDER = new RegExp( + `${SECRET_TOKEN_PATTERN}|${escapeRegExp(REDACTED_SECRET_VALUE)}|\\[REDACTED\\]`, + "g", +); + +export const findSecretTokens = (text: string) => + text.match(SECRET_TOKEN) ?? []; + +// A plain placeholder carries no id, so the value behind it cannot be put back. +export const hasUnrestorablePlaceholder = (text: string) => + (text.match(ANY_PLACEHOLDER) ?? []).some( + (placeholder) => !WHOLE_SECRET_TOKEN.test(placeholder), + ); + +export const replaceSecretTokens = ( + text: string, + resolve: (token: string) => string, +) => text.replace(SECRET_TOKEN, resolve); + const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g; @@ -183,98 +264,49 @@ const splitLongLine = (line: string) => { return pieces; }; -const scrubCredentialPatterns = (text: string) => { +type SecretReplacement = (secret: string) => string; + +const scrubCredentialPatterns = ( + text: string, + replacement: SecretReplacement | undefined, +) => { + const hide = replacement ?? (() => REDACTED_SECRET_VALUE); let scrubbed = text; for (const shape of TOKEN_SHAPES) { - scrubbed = scrubbed.replace(shape, REDACTED_SECRET_VALUE); + scrubbed = scrubbed.replace(shape, (secret) => hide(secret)); } return redactSensitiveText(scrubbed, { broadKeys: true, keepVariableReferences: true, + replacement, }); }; +// With `replacement` every hidden value becomes whatever it returns for that +// value, which is how restorable tokens are produced. export const scrubSecretText = ( text: string, matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, + replacement?: SecretReplacement, ) => { if (text === "") { return text; } + const hide = replacement ?? (() => REDACTED_SECRET_VALUE); const scrubbed = ( matcher.pattern - ? text.replace(matcher.pattern, REDACTED_SECRET_VALUE) + ? text.replace(matcher.pattern, (secret) => hide(secret)) : text - ).replace(PRIVATE_KEY_BLOCK, REDACTED_SECRET_VALUE); + ).replace(PRIVATE_KEY_BLOCK, (secret) => hide(secret)); if (!hasLongLine(scrubbed)) { - return scrubCredentialPatterns(scrubbed); + return scrubCredentialPatterns(scrubbed, replacement); } return scrubbed .split("\n") - .map((line) => splitLongLine(line).map(scrubCredentialPatterns).join("")) + .map((line) => + splitLongLine(line) + .map((piece) => scrubCredentialPatterns(piece, replacement)) + .join(""), + ) .join("\n"); }; - -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== "object") { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - -const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/; - -// Under a secret-bearing key every value is blanked. The names in a list of -// assignments (a docker `Env` array) and the keys of a map stay readable. -const blankSecretValue = (value: unknown, listItem = false): unknown => { - if (typeof value === "string") { - if (value === "") { - return value; - } - const assignment = listItem ? ENV_ASSIGNMENT.exec(value) : null; - return assignment - ? `${assignment[1]}=${REDACTED_SECRET_VALUE}` - : REDACTED_SECRET_VALUE; - } - if (Array.isArray(value)) { - return value.map((item) => blankSecretValue(item, true)); - } - if (isPlainObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, blankSecretValue(item)]), - ); - } - return value; -}; - -const scrubValue = ( - value: unknown, - key: string | null, - matcher: KnownSecretMatcher, -): unknown => { - if (typeof value === "string") { - return key !== null && IDENTITY_KEY.test(key) - ? value - : scrubSecretText(value, matcher); - } - if (Array.isArray(value)) { - return value.map((item) => scrubValue(item, key, matcher)); - } - if (!isPlainObject(value)) { - return value; - } - return Object.fromEntries( - Object.entries(value).map(([childKey, child]) => [ - childKey, - isSensitiveOutputKey(childKey) - ? blankSecretValue(child) - : scrubValue(child, childKey, matcher), - ]), - ); -}; - -export const scrubSecretOutput = ( - value: T, - matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, -): T => scrubValue(value, null, matcher) as T; From eb036f5112e62544967da1a456b2c67b03721931 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:36:39 -0400 Subject: [PATCH 4/5] fix(security): scrub values written with spaces around the separator or running to the end of the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Прогон на локальной панели показал утечку: строка `password = значение` (пробелы вокруг `=`, как в ini и cnf) не распознавалась, а правило для SQL принимало знак `=` за пароль, и само значение оставалось в ответе. - Широкий фильтр теперь понимает: присваивание с пробелами вокруг `=`; значение до конца строки, если строка целиком является присваиванием или парой `ключ: значение` (значение с пробелами); `'ключ' => 'значение'`; XML-элемент `значение`; числовое значение после `ключ: ` (раньше оно пропускалось как номер порта). - Для содержимого файлов монтирования дополнительно читаются настройки вида `имя значение` (`requirepass ...`). В логах такая форма не применяется: там это обычный текст. - Правило `PASSWORD значение` больше не принимает `=` и `:` за значение. - Метка внутри скрытого значения (строка скрыта целиком после того, как в ней был скрыт пароль) восстанавливается до конца. - Имя переменной внутри ссылки `${VAR:?текст}` не считается ключом. Зачем: - Конфигурационные файлы в монтированиях пишут секреты именно так, и агент получал бы их при чтении файла. Риски: - Фильтр стал шире: в логах строка, начинающаяся с `session=...` или `auth: ...`, скрывается до конца строки. - Секрет в файле произвольного формата под нейтральным именем или без имени (например, строка .pgpass) по-прежнему не распознаётся. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run __test__/security` - 332 passed; самая долгая проверка на враждебной строке 600 КБ - 276 мс; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: полный набор тестов после этого коммита запускается отдельно; на удалённой панели не проверялось. What: - A run against a local panel showed a leak: the line `password = value` (spaces around `=`, as in ini and cnf files) was not recognised, and the SQL rule took the `=` sign for the password, so the value itself stayed in the response. - The broad scrubber now understands: an assignment with spaces around `=`; a value running to the end of the line when the whole line is an assignment or a `key: value` pair (a value with spaces); `'key' => 'value'`; an XML element `value`; a numeric value after `key: ` (it used to be let through as a port number). - Mounted file contents are also read for `name value` settings (`requirepass ...`). Logs are not read that way: there the shape is ordinary prose. - The `PASSWORD value` rule no longer takes `=` or `:` for the value. - A token inside a hidden value (a line hidden whole after a password in it was hidden) is resolved all the way down. - A variable name inside a `${VAR:?text}` reference is not read as a key. Why: - Config files in mounts write secrets exactly this way, and an agent would have received them when reading the file. Risks: - The scrubber is broader: in logs a line starting with `session=...` or `auth: ...` is hidden to the end of the line. - A secret in a free-form file under a neutral name or with no name at all (a .pgpass line, for example) is still not recognised. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run __test__/security` - 332 passed; the slowest hostile 600 KB line check - 276 ms; `biome check` on the changed files - 0 errors. - Limitations: the full suite after this commit is run separately; not exercised against a remote panel. Co-Authored-By: Claude Opus 5.5 --- .../__test__/security/compose-secrets.test.ts | 5 +- .../__test__/security/secret-scrub.test.ts | 35 ++++++++++++ .../src/utils/security/compose-secrets.ts | 32 +++++++---- .../server/src/utils/security/redaction.ts | 55 ++++++++++++++++++- .../src/utils/security/secret-output.ts | 4 +- .../server/src/utils/security/secret-scrub.ts | 21 ++++--- 6 files changed, 128 insertions(+), 24 deletions(-) diff --git a/apps/dokploy/__test__/security/compose-secrets.test.ts b/apps/dokploy/__test__/security/compose-secrets.test.ts index 494363b5cbf..894e55c910d 100644 --- a/apps/dokploy/__test__/security/compose-secrets.test.ts +++ b/apps/dokploy/__test__/security/compose-secrets.test.ts @@ -355,7 +355,10 @@ describe("restoreTextSecrets", () => { "}", "", ].join("\n"); - const shown = scrubSecretText(stored, matcher, secretTokenFor); + const shown = scrubSecretText(stored, matcher, { + replacement: secretTokenFor, + file: true, + }); it("hides the values behind tokens", () => { for (const secret of [ diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts index 02584097dd4..d7151fe90c0 100644 --- a/apps/dokploy/__test__/security/secret-scrub.test.ts +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -328,6 +328,41 @@ describe("scrubSecretText", () => { expect(performance.now() - startedAt).toBeLessThan(3_000); }); + it.each([ + ["password = correct horse battery", `password = ${R}`], + [" db.password = s3cr3tValue", ` db.password = ${R}`], + ['password = "quoted value"', `password = ${R}`], + ["password: correct horse battery", `password: ${R}`], + ["password: 12345", `password: ${R}`], + [" - api_key: abc def", ` - api_key: ${R}`], + ["retry with token = abc123 now", `retry with token = ${R} now`], + ["'password' => 'abc'", `'password' => ${R}`], + ["abc def", `${R}`], + ['', ``], + ])("scrubs the value in %s", (input, expected) => { + expect(scrubSecretText(input)).toBe(expected); + }); + + it.each([ + "if (password == null) return", + "password =~ /^.{12,}$/", + "version=1.2.3 build=42", + ])("leaves %s alone", (input) => { + expect(scrubSecretText(input)).toBe(input); + }); + + it("reads `name value` settings only in a file", () => { + const file = + "requirepass hunter2-hunter2\nauth_basic_user_file /etc/users;\nlisten 8080;\n"; + + expect(scrubSecretText(file, undefined, { file: true })).toBe( + `requirepass ${R}\nauth_basic_user_file ${R};\nlisten 8080;\n`, + ); + expect(scrubSecretText("Passing health check on port 8080")).toBe( + "Passing health check on port 8080", + ); + }); + it("is idempotent", () => { const once = scrubSecretText( "DB_PASS=abc postgres://u:p4ssw0rd@h/db Authorization: Bearer x.y.z", diff --git a/packages/server/src/utils/security/compose-secrets.ts b/packages/server/src/utils/security/compose-secrets.ts index 921af56aecc..6bd16bdeb82 100644 --- a/packages/server/src/utils/security/compose-secrets.ts +++ b/packages/server/src/utils/security/compose-secrets.ts @@ -288,7 +288,7 @@ const hideScalar = ( if (typeof value !== "string") { return null; } - const scrubbed = scrubSecretText(text, matcher, replacement); + const scrubbed = scrubSecretText(text, matcher, { replacement }); return scrubbed === text ? null : scrubbed; }; @@ -326,7 +326,7 @@ export const hideComposeSecrets = ( }); for (const [start, end] of commentRanges(text, scalarRanges)) { const comment = text.slice(start, end); - const scrubbed = scrubSecretText(comment, matcher, replacement); + const scrubbed = scrubSecretText(comment, matcher, { replacement }); if (scrubbed !== comment) { edits.push({ start, end, text: scrubbed }); } @@ -402,14 +402,19 @@ const buildVault = ( return vault; }; -const resolveToken = (vault: Map) => (token: string) => { - const value = vault.get(token); - if (value === undefined) { - throw new SecretRestoreError( - "The text contains a token that does not belong to the stored version. Read it again and keep its tokens unchanged", - ); - } - return value; +// A hidden value may itself hold a token (a line hidden whole after a +// password inside it was), so a token is resolved all the way down. +const resolveToken = (vault: Map) => { + const resolve = (token: string, depth = 0): string => { + const value = vault.get(token); + if (value === undefined || depth > 8) { + throw new SecretRestoreError( + "The text contains a token that does not belong to the stored version. Read it again and keep its tokens unchanged", + ); + } + return replaceSecretTokens(value, (inner) => resolve(inner, depth + 1)); + }; + return (token: string) => resolve(token); }; const assertRestorable = (submitted: string) => { @@ -490,13 +495,16 @@ export const restoreTextSecrets = ( } const resolve = resolveToken( buildVault((replacement) => - scrubSecretText(stored ?? "", matcher, replacement), + scrubSecretText(stored ?? "", matcher, { replacement, file: true }), ), ); const restored = replaceSecretTokens(submitted, resolve); assertStillHidden( submitted, - scrubSecretText(restored, matcher, secretTokenFor), + scrubSecretText(restored, matcher, { + replacement: secretTokenFor, + file: true, + }), ); return restored; }; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 68fce1e1028..400277f40d4 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -444,10 +444,30 @@ const TAG_OR_PORT_PATTERN = String.raw`(?:\d{1,5}|v?\d+(?:\.\d+)+[\w.-]*|latest| // like a key with a scalar value on the same line. Otherwise the short // keywords would read `auth-db:5432` in a URL, the image `auth-api:1.2.3` or // the YAML mapping `auth-router:` as a secret. +const KEY_NAME_HEAD = String.raw`[\w.-]{0,60}?`; +const KEY_NAME_TAIL = String.raw`[\w.-]{0,80}`; +const BROAD_KEY = `${BROAD_SENSITIVE_KEY_PATTERN}${KEY_NAME_TAIL}`; +// `==`, `=>` and `=~` compare or map; they do not assign. +const ASSIGN = String.raw`[ \t]*=(?![=>~])[ \t]*`; + const BROAD_KEY_PATTERNS: KeyPatterns = { beforeAuthorization: [ + // A line that is one assignment: the value runs to the end of the line, + // spaces included (`password = correct horse`). + new RegExp( + `^([ \\t]*(?:(?:export|set|setenv)[ \\t]+)?["']?${KEY_NAME_HEAD}${BROAD_KEY}["']?${ASSIGN})(\\S.*)$`, + "gim", + ), + new RegExp( + `(${BROAD_SENSITIVE_KEY_PATTERN}[\\w.\\[\\]"'-]{0,80}${ASSIGN})(${SHELL_VALUE_PATTERN})`, + "gi", + ), new RegExp( - `(${BROAD_SENSITIVE_KEY_PATTERN}[^=\\s&#?]{0,80}=)(${SHELL_VALUE_PATTERN})`, + `(${BROAD_KEY}["']?[ \\t]*=>[ \\t]*)("[^"]*"|'[^']*'|[^\\s,;)\\]}]+)`, + "gi", + ), + new RegExp( + `(<[\\w:.-]{0,60}?${BROAD_SENSITIVE_KEY_PATTERN}[\\w:.-]{0,80}>)([^<\\n]+)`, "gi", ), ], @@ -456,8 +476,18 @@ const BROAD_KEY_PATTERNS: KeyPatterns = { `(${BROAD_SENSITIVE_KEY_PATTERN}[^"'\\n]{0,80}["'][ \\t]*:[ \\t]*)(?![{[])(${KEY_VALUE_PATTERN})`, "gi", ), + // `key: value` on its own line, the value again running to the end. new RegExp( - `(?<=(?&*!])(\\S.*)$`, + "gim", + ), + new RegExp( + `(?<=(?)(\\S.*?)(;?[ \\t\\r]*)$`, + "gim", +); + const SECRET_LOOKING_NAME = new RegExp(BROAD_SENSITIVE_KEY_PATTERN, "i"); export const isSecretLookingName = (name: string) => @@ -498,6 +535,8 @@ const ALREADY_HIDDEN_VALUE = new RegExp( export type RedactSensitiveTextOptions = { keepVariableReferences?: boolean; broadKeys?: boolean; + // Read `name value` lines as settings; for files, not for logs. + directives?: boolean; // Produces what stands in for a secret; defaults to the plain placeholder. replacement?: (secret: string) => string; }; @@ -576,6 +615,16 @@ export function redactSensitiveText( redacted = redacted.replace(pattern, hideValue); } + if (options.directives) { + redacted = redacted.replace( + DIRECTIVE_PATTERN, + (match, prefix: string, secret: string, end: string) => + keepVariableReferences && isVariableReference(secret) + ? match + : `${prefix}${hide(secret)}${end}`, + ); + } + // mysql/mariadb clients take the password glued to -p. A separate word after // -p is not a password, uppercase -P is the port, and -p of other tools // (docker -p, ssh -p, compose -p) is a port or project name. @@ -588,7 +637,7 @@ export function redactSensitiveText( ); redacted = redacted.replace( - /(\b(? { type SecretReplacement = (secret: string) => string; +export type ScrubSecretTextOptions = { + // With it every hidden value becomes whatever it returns for that value, + // which is how restorable tokens are produced. + replacement?: SecretReplacement; + // The text is a config file, where `name value` lines are settings. + file?: boolean; +}; + const scrubCredentialPatterns = ( text: string, - replacement: SecretReplacement | undefined, + { replacement, file }: ScrubSecretTextOptions, ) => { const hide = replacement ?? (() => REDACTED_SECRET_VALUE); let scrubbed = text; @@ -278,34 +286,33 @@ const scrubCredentialPatterns = ( return redactSensitiveText(scrubbed, { broadKeys: true, keepVariableReferences: true, + directives: file, replacement, }); }; -// With `replacement` every hidden value becomes whatever it returns for that -// value, which is how restorable tokens are produced. export const scrubSecretText = ( text: string, matcher: KnownSecretMatcher = EMPTY_KNOWN_SECRET_MATCHER, - replacement?: SecretReplacement, + options: ScrubSecretTextOptions = {}, ) => { if (text === "") { return text; } - const hide = replacement ?? (() => REDACTED_SECRET_VALUE); + const hide = options.replacement ?? (() => REDACTED_SECRET_VALUE); const scrubbed = ( matcher.pattern ? text.replace(matcher.pattern, (secret) => hide(secret)) : text ).replace(PRIVATE_KEY_BLOCK, (secret) => hide(secret)); if (!hasLongLine(scrubbed)) { - return scrubCredentialPatterns(scrubbed, replacement); + return scrubCredentialPatterns(scrubbed, options); } return scrubbed .split("\n") .map((line) => splitLongLine(line) - .map((piece) => scrubCredentialPatterns(piece, replacement)) + .map((piece) => scrubCredentialPatterns(piece, options)) .join(""), ) .join("\n"); From 485c516fc60f7e9723cacc084a218bce87e175ee Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:32:21 -0400 Subject: [PATCH 5/5] feat(security): hide generated-looking values under any name in mounted files, labels and commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - В содержимом файлов монтирования для API-ключа скрывается значение настройки (`имя = значение`, `имя: значение`), которое выглядит как сгенерированный ключ, как бы настройка ни называлась: от 16 символов из букв, цифр и `_+-`, с буквами и цифрами, и при этом смешанный регистр, либо четыре и более цифр, либо ни одного разделителя. - В compose-файле то же правило применяется к словам в labels, command, entrypoint и healthcheck test: значение метки, позиционный аргумент команды. - Имена настроек и меток остаются видны. Имена вроде `backend-prod-eu-west-01`, адреса, пути, URL, версии и числа этим правилом не затрагиваются. - Скрытые так значения получают метки и возвращаются на место при сохранении, как и остальные. Зачем: - Владелец хочет, чтобы агент читал файлы целиком, но не видел самих значений секретов. Раньше значение под нейтральным именем вне блоков переменных оставалось видимым. Риски: - Правило не действует на логи: там так выглядят идентификаторы запросов. - Секрет из обычных слов без цифр, записанный под нейтральным именем вне блоков переменных, по-прежнему не распознаётся. - Несекретное значение похожего вида (контрольная сумма, UUID) тоже скрывается; метка позволяет сохранить файл без потери. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2885 passed, 1 skipped; самая долгая проверка на враждебной строке 600 КБ - 270 мс; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на удалённой панели не проверялось. What: - In mounted file contents read with an API key, a setting value (`name = value`, `name: value`) that looks like a generated key is hidden whatever the setting is called: 16 or more characters of letters, digits and `_+-`, with both letters and digits, and either mixed case, four or more digits, or no separator at all. - In a compose file the same rule applies to the words of labels, command, entrypoint and healthcheck test: a label value, a positional command argument. - Setting and label names stay visible. Names such as `backend-prod-eu-west-01`, hosts, paths, URLs, versions and numbers are not touched by this rule. - Values hidden this way get tokens and are put back on save, like the others. Why: - The owner wants an agent to read files whole without seeing the secret values themselves. A value under a neutral name outside the variable blocks used to stay visible. Risks: - The rule does not apply to logs: request ids look like that there. - A secret made of ordinary words without digits, written under a neutral name outside the variable blocks, is still not recognised. - A non-secret value of the same look (a checksum, a UUID) is hidden too; its token lets the file be saved without loss. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2885 passed, 1 skipped; the slowest hostile 600 KB line check - 270 ms; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a remote panel. Co-Authored-By: Claude Opus 5.5 --- .../__test__/security/compose-secrets.test.ts | 44 +++++++++++++++++++ .../__test__/security/secret-scrub.test.ts | 31 +++++++++++++ .../src/utils/security/compose-secrets.ts | 25 ++++++++++- .../server/src/utils/security/redaction.ts | 37 ++++++++++++++++ .../server/src/utils/security/secret-scrub.ts | 5 ++- 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/security/compose-secrets.test.ts b/apps/dokploy/__test__/security/compose-secrets.test.ts index 894e55c910d..e6dd26ea76a 100644 --- a/apps/dokploy/__test__/security/compose-secrets.test.ts +++ b/apps/dokploy/__test__/security/compose-secrets.test.ts @@ -149,6 +149,50 @@ describe("hideComposeSecrets", () => { expect(hide(text, matcher)).not.toContain("Known-Value-77"); }); + it("hides generated-looking values in labels and commands, keeps names and routing", () => { + const text = [ + "services:", + " api:", + " image: registry.example.test/team/api:1.0.0", + " container_name: backend-prod-eu-west-01", + " hostname: api-internal-node-02", + ' command: ["server", "--mode", "fast", "Zx9PositionalValue42"]', + " labels:", + " note: Qw7-label-Neutral-0099", + " team: platform-services-eu", + " web:", + " image: web:2", + " entrypoint: run Ab12Cd34Ef56Gh78 --verbose", + " labels:", + ' - "traefik.http.routers.web-a1b2c3d4e5f6a7b8.rule=Host(`web.example.test`)"', + ' - "owner=Qw7-list-Neutral-0077"', + "", + ].join("\n"); + const hidden = hide(text); + + for (const secret of [ + "Zx9PositionalValue42", + "Qw7-label-Neutral-0099", + "Ab12Cd34Ef56Gh78", + "Qw7-list-Neutral-0077", + ]) { + expect(hidden).not.toContain(secret); + } + for (const kept of [ + "container_name: backend-prod-eu-west-01", + "hostname: api-internal-node-02", + "team: platform-services-eu", + "traefik.http.routers.web-a1b2c3d4e5f6a7b8.rule=Host(`web.example.test`)", + "owner=", + "note: ", + '"--mode"', + "--verbose", + ]) { + expect(hidden).toContain(kept); + } + expect(parse(restore(hidden, text))).toEqual(parse(text)); + }); + it("shows nothing of a file that does not parse", () => { expect(hide("services:\n a:\n environment:\n X: [unclosed\n")).toBe( BARE, diff --git a/apps/dokploy/__test__/security/secret-scrub.test.ts b/apps/dokploy/__test__/security/secret-scrub.test.ts index d7151fe90c0..a053714a10a 100644 --- a/apps/dokploy/__test__/security/secret-scrub.test.ts +++ b/apps/dokploy/__test__/security/secret-scrub.test.ts @@ -363,6 +363,37 @@ describe("scrubSecretText", () => { ); }); + it("hides a generated-looking value under any name in a file, and nothing else", () => { + const file = [ + "greeting = Zx9-neutral-Value42x", + "checksum: 9f86d081884c7d659a2feaa0c55ad015", + 'label = "AbCdEf0123456789xyz"', + "job_name: node-exporter-prod-01", + "server_name app.example.test;", + "upstream = http://backend-service-01:8080/", + "root = /var/www/releases/2026-10-12", + "workers = 1024", + "", + ].join("\n"); + + expect(scrubSecretText(file, undefined, { file: true })).toBe( + [ + `greeting = ${R}`, + `checksum: ${R}`, + `label = "${R}"`, + "job_name: node-exporter-prod-01", + "server_name app.example.test;", + "upstream = http://backend-service-01:8080/", + "root = /var/www/releases/2026-10-12", + "workers = 1024", + "", + ].join("\n"), + ); + expect(scrubSecretText("request id=Zx9-neutral-Value42x done")).toBe( + "request id=Zx9-neutral-Value42x done", + ); + }); + it("is idempotent", () => { const once = scrubSecretText( "DB_PASS=abc postgres://u:p4ssw0rd@h/db Authorization: Bearer x.y.z", diff --git a/packages/server/src/utils/security/compose-secrets.ts b/packages/server/src/utils/security/compose-secrets.ts index 6bd16bdeb82..e28a8704db8 100644 --- a/packages/server/src/utils/security/compose-secrets.ts +++ b/packages/server/src/utils/security/compose-secrets.ts @@ -204,8 +204,18 @@ type ScalarVisit = { node: Scalar; position: "key" | "value" | "item"; parentKey: string | null; + // Inside `labels`, `command` and the like, where a secret has no name. + freeForm: boolean; }; +const FREE_FORM_BLOCKS = new Set([ + "annotations", + "command", + "entrypoint", + "labels", + "test", +]); + const visitScalars = ( document: Document, visitor: (scalar: ScalarVisit & { range: [number, number] }) => void, @@ -228,6 +238,14 @@ const visitScalars = ( range, position: key === "key" ? "key" : key === "value" ? "value" : "item", parentKey: pairKey, + freeForm: + key !== "key" && + path.some( + (ancestor) => + "key" in ancestor && + isScalar(ancestor.key) && + FREE_FORM_BLOCKS.has(String(ancestor.key.value)), + ), }); }, }); @@ -259,7 +277,7 @@ const commentRanges = (text: string, scalarRanges: [number, number][]) => { }; const hideScalar = ( - { node, position, parentKey }: ScalarVisit, + { node, position, parentKey, freeForm }: ScalarVisit, slot: SlotKind | undefined, matcher: KnownSecretMatcher, replacement: SecretReplacement, @@ -288,7 +306,10 @@ const hideScalar = ( if (typeof value !== "string") { return null; } - const scrubbed = scrubSecretText(text, matcher, { replacement }); + const scrubbed = scrubSecretText(text, matcher, { + replacement, + words: freeForm, + }); return scrubbed === text ? null : scrubbed; }; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 400277f40d4..ddca8b0c669 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -504,6 +504,32 @@ const DIRECTIVE_PATTERN = new RegExp( "gim", ); +// A generated credential is a long run of letters and digits with nothing that +// marks a host, a path or a URL. It is told apart from a name such as +// `backend-prod-eu-01` by mixed case, several digits, or no separators at all. +const OPAQUE_RUN = "[A-Za-z0-9_+-]{16,}={0,2}"; +const WHOLE_OPAQUE_RUN = new RegExp(`^${OPAQUE_RUN}$`); + +export const isOpaqueValue = (value: string) => + WHOLE_OPAQUE_RUN.test(value) && + /[A-Za-z]/.test(value) && + /\d/.test(value) && + ((/[a-z]/.test(value) && /[A-Z]/.test(value)) || + (value.match(/\d/g)?.length ?? 0) >= 4 || + !/[-_]/.test(value)); + +// In a file a setting may hold such a value under any name at all. +const OPAQUE_SETTING_PATTERN = new RegExp( + `^([ \\t]*(?:-[ \\t]+)?["']?[\\w.-]{1,80}["']?[ \\t]*(?:=(?![=>~])|:[ \\t])[ \\t]*["']?)(${OPAQUE_RUN})(?=["']?[ \\t\\r]*[;,]?[ \\t\\r]*$)`, + "gm", +); + +// A word of a command line or a label that looks generated. +const OPAQUE_WORD_PATTERN = new RegExp( + `(^|[\\s="'])(${OPAQUE_RUN})(?=$|[\\s"'])`, + "g", +); + const SECRET_LOOKING_NAME = new RegExp(BROAD_SENSITIVE_KEY_PATTERN, "i"); export const isSecretLookingName = (name: string) => @@ -537,6 +563,9 @@ export type RedactSensitiveTextOptions = { broadKeys?: boolean; // Read `name value` lines as settings; for files, not for logs. directives?: boolean; + // Also hide generated-looking values under any name: `file` for the + // settings of a config file, `words` for the words of a command or label. + opaque?: "file" | "words"; // Produces what stands in for a secret; defaults to the plain placeholder. replacement?: (secret: string) => string; }; @@ -625,6 +654,14 @@ export function redactSensitiveText( ); } + if (options.opaque) { + redacted = redacted.replace( + options.opaque === "file" ? OPAQUE_SETTING_PATTERN : OPAQUE_WORD_PATTERN, + (match, prefix: string, secret: string) => + isOpaqueValue(secret) ? `${prefix}${hide(secret)}` : match, + ); + } + // mysql/mariadb clients take the password glued to -p. A separate word after // -p is not a password, uppercase -P is the port, and -p of other tools // (docker -p, ssh -p, compose -p) is a port or project name. diff --git a/packages/server/src/utils/security/secret-scrub.ts b/packages/server/src/utils/security/secret-scrub.ts index c28b3c131c5..2e88060a60a 100644 --- a/packages/server/src/utils/security/secret-scrub.ts +++ b/packages/server/src/utils/security/secret-scrub.ts @@ -272,11 +272,13 @@ export type ScrubSecretTextOptions = { replacement?: SecretReplacement; // The text is a config file, where `name value` lines are settings. file?: boolean; + // The text is a command line or a label: hide its generated-looking words. + words?: boolean; }; const scrubCredentialPatterns = ( text: string, - { replacement, file }: ScrubSecretTextOptions, + { replacement, file, words }: ScrubSecretTextOptions, ) => { const hide = replacement ?? (() => REDACTED_SECRET_VALUE); let scrubbed = text; @@ -287,6 +289,7 @@ const scrubCredentialPatterns = ( broadKeys: true, keepVariableReferences: true, directives: file, + opaque: file ? "file" : words ? "words" : undefined, replacement, }); };