From 98818c105e8249bd28e140a387f3199a156f60fa Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 7 Sep 2026 15:27:17 +0900 Subject: [PATCH] fix(config): omit webhook credentials from exports --- src/cli/config-command.ts | 12 ++++- tests/usage/quota-reset-notify.test.ts | 64 ++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index cd33fefd74..f69110018b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -38,6 +38,16 @@ function redact(value: unknown, key = ""): unknown { return value; } +function omitWebhookCredentials(value: unknown): unknown { + if (Array.isArray(value)) return value.map(omitWebhookCredentials); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => key.toLowerCase() !== "webhookurl") + .map(([key, child]) => [key, omitWebhookCredentials(child)]), + ); +} + function pathSegments(path: string): string[] { const segments = path.split(".").map(part => part.trim()).filter(Boolean); if (segments.length === 0 || segments.some(part => BLOCKED_SEGMENTS.has(part))) throw new CliUsageError("invalid config path", USAGE); @@ -195,7 +205,7 @@ export async function handleConfigCommand(argv: string[]): Promise { const path = args.shift(); if (!path) throw new CliUsageError("export path is required", USAGE); rejectArgs(args, USAGE); - const content = `${JSON.stringify(readConfigDiagnostics().config, null, 2)}\n`; + const content = `${JSON.stringify(omitWebhookCredentials(readConfigDiagnostics().config), null, 2)}\n`; if (path === "-") process.stdout.write(content); else { writeFileSync(path, content, { encoding: "utf8", mode: 0o600 }); console.log(`Exported config to ${path}.`); } return; diff --git a/tests/usage/quota-reset-notify.test.ts b/tests/usage/quota-reset-notify.test.ts index 6a503e69a5..519e68e869 100644 --- a/tests/usage/quota-reset-notify.test.ts +++ b/tests/usage/quota-reset-notify.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; @@ -366,12 +366,8 @@ describe("config integration", () => { }); describe("webhookUrl is treated as a credential", () => { - test("ocx config show does not print it", async () => { - // For Slack and Discord the URL IS the authorization: anyone holding it can post to the - // channel. It matches none of the pre-existing secret-key patterns, so it had to be named - // explicitly — before that, `config show` printed it and `config export` wrote it to disk. + function configureWebhook(secret: string): { home: string; restore: () => void } { const home = mkdtempSync(join(tmpdir(), "ocx-redact-")); - const secret = "https://hooks.slack.com/services/T00000/B00000/zzTOKENzz"; writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10100, defaultProvider: "openai", @@ -380,9 +376,23 @@ describe("webhookUrl is treated as a credential", () => { }, quotaResetNotify: { enabled: true, webhookUrl: secret }, })); - const previousHome = process.env["OPENCODEX_HOME"]; process.env["OPENCODEX_HOME"] = home; + return { + home, + restore: () => { + if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = previousHome; + }, + }; + } + + test("ocx config show does not print it", async () => { + // For Slack and Discord the URL IS the authorization: anyone holding it can post to the + // channel. It matches none of the pre-existing secret-key patterns, so it had to be named + // explicitly — before that, `config show` printed it and `config export` wrote it to disk. + const secret = "https://hooks.slack.com/services/T00000/B00000/zzTOKENzz"; + const configured = configureWebhook(secret); const written: string[] = []; const originalLog = console.log; console.log = (...args: unknown[]) => { written.push(args.map(String).join(" ")); }; @@ -395,8 +405,44 @@ describe("webhookUrl is treated as a credential", () => { expect(output).toContain("********"); } finally { console.log = originalLog; - if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; - else process.env["OPENCODEX_HOME"] = previousHome; + configured.restore(); + } + }); + + test("ocx config export omits it from stdout", async () => { + const secret = "https://hooks.slack.com/services/T00000/B00000/stdoutTOKEN"; + const configured = configureWebhook(secret); + const written: string[] = []; + const originalWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + expect(await handleConfigCommand(["export", "-"])).toBe(0); + const exported = JSON.parse(written.join("")) as Record; + expect(JSON.stringify(exported)).not.toContain(secret); + expect(exported["quotaResetNotify"]).toEqual({ enabled: true }); + } finally { + process.stdout.write = originalWrite; + configured.restore(); + } + }); + + test("ocx config export omits it from a file", async () => { + const secret = "https://hooks.discord.com/api/webhooks/fileTOKEN"; + const configured = configureWebhook(secret); + const outputPath = join(configured.home, "export.json"); + const originalLog = console.log; + console.log = () => {}; + try { + expect(await handleConfigCommand(["export", outputPath])).toBe(0); + const exported = JSON.parse(readFileSync(outputPath, "utf8")) as Record; + expect(JSON.stringify(exported)).not.toContain(secret); + expect(exported["quotaResetNotify"]).toEqual({ enabled: true }); + } finally { + console.log = originalLog; + configured.restore(); } }); });