From 7951b60bc303467cbcb6b002e0b2a7c74f68f5e3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:37:12 -0300 Subject: [PATCH] feat(cli): add auth export command for decrypted provider credentials (#6683) (#7724) --- bin/cli/commands/auth-export.mjs | 163 +++++++++ bin/cli/commands/registry.mjs | 2 + bin/cli/locales/en.json | 14 + bin/cli/locales/pt-BR.json | 14 + changelog.d/features/6683-cli-auth-export.md | 1 + docs/reference/CLI-TOOLS.md | 17 + tests/unit/cli-auth-export-command.test.ts | 334 +++++++++++++++++++ 7 files changed, 545 insertions(+) create mode 100644 bin/cli/commands/auth-export.mjs create mode 100644 changelog.d/features/6683-cli-auth-export.md create mode 100644 tests/unit/cli-auth-export-command.test.ts diff --git a/bin/cli/commands/auth-export.mjs b/bin/cli/commands/auth-export.mjs new file mode 100644 index 0000000000..c968f0dab3 --- /dev/null +++ b/bin/cli/commands/auth-export.mjs @@ -0,0 +1,163 @@ +import { chmodSync, existsSync, writeFileSync } from "node:fs"; +import { decryptCredential } from "../encryption.mjs"; +import { findProviderConnection, listProviderConnections } from "../provider-store.mjs"; +import { openOmniRouteDb } from "../sqlite.mjs"; +import { t } from "../i18n.mjs"; + +/** + * Local-only, operator-invoked command that dumps DECRYPTED provider credentials + * (apiKey/accessToken/refreshToken/idToken). This never runs inside the HTTP server + * process and must never be reachable over the network — no src/app/api/ route wraps + * this. See docs/security/ for the threat-model writeup referenced in issue #6683. + */ + +const CREDENTIAL_FIELDS = [ + { key: "apiKey", envSuffix: "API_KEY" }, + { key: "accessToken", envSuffix: "ACCESS_TOKEN" }, + { key: "refreshToken", envSuffix: "REFRESH_TOKEN" }, + { key: "idToken", envSuffix: "ID_TOKEN" }, +]; + +const VALID_FORMATS = new Set(["json", "env"]); +const SECURE_FILE_MODE = 0o600; + +export function registerAuthExport(program) { + program + .command("auth export") + .description(t("authExport.description")) + .option("--id ", t("authExport.idOpt")) + .option("--format ", t("authExport.formatOpt"), "json") + .option("--out ", t("authExport.outOpt")) + .option("--force", t("authExport.forceOpt")) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runAuthExportCommand({ ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runAuthExportCommand(opts = {}) { + // Security control (a): confirmation gate BEFORE any DB access — a dry invocation + // never opens the database and never decrypts anything. + if (!opts.force) { + printConfirmationGate(); + return 0; + } + + const format = opts.format || "json"; + if (!VALID_FORMATS.has(format)) { + console.error(t("authExport.invalidFormat", { format })); + return 1; + } + + if (!process.env.STORAGE_ENCRYPTION_KEY) { + console.error(t("authExport.missingKey")); + return 1; + } + + // Security control (b): stderr warning banner BEFORE any plaintext is emitted. + process.stderr.write(t("authExport.warning") + "\n"); + + const rows = await loadTargetConnections(opts.id); + if (rows === null) { + console.error(t("authExport.notFound", { id: opts.id })); + return 1; + } + + const exported = rows.map(exportConnection); + const content = format === "env" ? formatAsEnv(exported) : formatAsJson(exported); + + if (opts.out) { + writeSecureFile(opts.out, content); + } else { + console.log(content); + } + + return 0; +} + +function printConfirmationGate() { + console.log( + `\n${t("authExport.confirmHeading")}\n\n${t("authExport.confirmBody")}\n\n${t("authExport.confirmFooter")}\n` + ); +} + +async function loadTargetConnections(id) { + const { db } = await openOmniRouteDb(); + try { + if (!id) return listProviderConnections(db); + const connection = findProviderConnection(db, id); + return connection ? [connection] : null; + } finally { + db.close(); + } +} + +function decryptField(rawValue) { + // Security control (d): a per-field decrypt failure surfaces as a boolean flag, + // never the caught error text. Security control (e): the caught error is never + // interpolated into any message. + try { + return { value: decryptCredential(rawValue), failed: false }; + } catch { + return { value: null, failed: true }; + } +} + +function exportConnection(connection) { + const result = { + id: connection.id, + provider: connection.provider, + name: connection.name, + authType: connection.authType, + }; + + for (const { key } of CREDENTIAL_FIELDS) { + const rawValue = connection[key]; + if (!rawValue) { + result[key] = null; + result[`${key}DecryptFailed`] = false; + continue; + } + const { value, failed } = decryptField(rawValue); + result[key] = value; + result[`${key}DecryptFailed`] = failed; + } + + return result; +} + +function formatAsJson(rows) { + return JSON.stringify(rows, null, 2); +} + +function envSafeSegment(value) { + return String(value || "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function formatAsEnv(rows) { + const lines = []; + for (const row of rows) { + lines.push(`# ${row.provider} (${row.id})`); + const providerSegment = envSafeSegment(row.provider); + for (const { key, envSuffix } of CREDENTIAL_FIELDS) { + const value = row[key]; + if (!value) continue; + lines.push(`OMNIROUTE_${providerSegment}_${envSuffix}=${value}`); + } + } + return lines.join("\n"); +} + +function writeSecureFile(filePath, content) { + // Security control (c): file output written with mode 0o600 (plus chmodSync if the + // file pre-existed, belt-and-suspenders against an already world-readable file). + const preExisted = existsSync(filePath); + writeFileSync(filePath, content, { mode: SECURE_FILE_MODE }); + if (preExisted) { + chmodSync(filePath, SECURE_FILE_MODE); + } +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index a2a91bb8c8..010392f601 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -37,6 +37,7 @@ import { registerProviders } from "./providers.mjs"; import { registerProvider } from "./provider-cmd.mjs"; import { registerConfig } from "./config.mjs"; import { registerKeys } from "./keys.mjs"; +import { registerAuthExport } from "./auth-export.mjs"; import { registerModels } from "./models.mjs"; import { registerCombo } from "./combo.mjs"; import { registerStatus } from "./status.mjs"; @@ -118,6 +119,7 @@ export function registerCommands(program) { registerProvider(program); registerConfig(program); registerKeys(program); + registerAuthExport(program); registerModels(program); registerCombo(program); registerStatus(program); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 9d436d1d79..d0d4808e16 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -133,6 +133,20 @@ "listTitle": "Keys expiring within {days} days:" } }, + "authExport": { + "description": "Export DECRYPTED provider credentials (local-only, plaintext output)", + "idOpt": "Export only the connection matching this id/name/provider", + "formatOpt": "Output format: json or env", + "outOpt": "Write output to a file instead of stdout (written with 0600 permissions)", + "forceOpt": "Confirm you understand this prints/writes plaintext secrets", + "warning": "⚠ This prints/writes DECRYPTED plaintext API keys and OAuth tokens. Make sure your screen, shell history, and any output file stay private.", + "confirmHeading": "⚠ WARNING: this exports DECRYPTED provider credentials in plaintext", + "confirmBody": "This command decrypts and prints/writes apiKey, accessToken, refreshToken, and\nidToken for the selected connection(s). Treat the output as a secret.", + "confirmFooter": "To confirm, run:\n omniroute auth export --force", + "missingKey": "STORAGE_ENCRYPTION_KEY is required to export credentials.", + "notFound": "Connection not found: {id}", + "invalidFormat": "Invalid format: {format}. Use json or env." + }, "stream": { "description": "Stream a chat response with SSE inspection modes", "file": "Read prompt from file", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 642fdc8d82..a951491d63 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -132,6 +132,20 @@ "listTitle": "Chaves que expiram nos próximos {days} dias:" } }, + "authExport": { + "description": "Exportar credenciais DESCRIPTOGRAFADAS de provedores (somente local, saída em texto puro)", + "idOpt": "Exportar apenas a conexão correspondente a este id/nome/provedor", + "formatOpt": "Formato de saída: json ou env", + "outOpt": "Gravar a saída em um arquivo em vez do stdout (gravado com permissão 0600)", + "forceOpt": "Confirma que você entende que isso imprime/grava segredos em texto puro", + "warning": "⚠ Isso imprime/grava chaves de API e tokens OAuth DESCRIPTOGRAFADOS em texto puro. Garanta que sua tela, o histórico do shell e qualquer arquivo de saída permaneçam privados.", + "confirmHeading": "⚠ AVISO: isso exporta credenciais de provedores DESCRIPTOGRAFADAS em texto puro", + "confirmBody": "Este comando descriptografa e imprime/grava apiKey, accessToken, refreshToken e\nidToken da(s) conexão(ões) selecionada(s). Trate a saída como um segredo.", + "confirmFooter": "Para confirmar, execute:\n omniroute auth export --force", + "missingKey": "STORAGE_ENCRYPTION_KEY é obrigatório para exportar credenciais.", + "notFound": "Conexão não encontrada: {id}", + "invalidFormat": "Formato inválido: {format}. Use json ou env." + }, "stream": { "description": "Transmitir resposta de chat com modos de inspeção SSE", "file": "Ler prompt de arquivo", diff --git a/changelog.d/features/6683-cli-auth-export.md b/changelog.d/features/6683-cli-auth-export.md new file mode 100644 index 0000000000..f3a1da7212 --- /dev/null +++ b/changelog.d/features/6683-cli-auth-export.md @@ -0,0 +1 @@ +- **feat(cli):** new `omniroute auth export` command dumps DECRYPTED provider credentials (`apiKey`/`accessToken`/`refreshToken`/`idToken`) for one connection (`--id `) or all connections, as `json` or `env` (`--format`), local-only and gated behind `--force` — no DB access happens without it, a stderr warning banner prints before any plaintext, `--out ` writes with `0600` permissions, and per-field decrypt failures surface as a `DecryptFailed` boolean instead of aborting the export or leaking the caught error text (#6683) diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 6624d8ccff..0e74e79977 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -655,6 +655,23 @@ omniroute reset-encrypted-columns # Show warning + dry-run for encrypted c omniroute reset-encrypted-columns --force # Actually null out encrypted credentials in SQLite ``` +### Credential Export (⚠ handle with care) + +```bash +omniroute auth export # Show warning + confirmation gate — no DB access +omniroute auth export --force # Export ALL connections' DECRYPTED credentials to stdout as JSON +omniroute auth export --force --id # Export only the matching connection +omniroute auth export --force --format env # Emit OMNIROUTE__= lines +omniroute auth export --force --out creds.json # Write to a file (created with 0600 permissions) +``` + +`auth export` is **local-only** (direct SQLite read, no HTTP route) and intentionally prints/writes +**plaintext** `apiKey`/`accessToken`/`refreshToken`/`idToken` values — that is the feature, not a +bug. Nothing is read from the database, and nothing is decrypted, without `--force`. A stderr +warning banner always prints before any plaintext is emitted. Requires `STORAGE_ENCRYPTION_KEY` to +be set. A field that fails to decrypt (stale key, corrupt ciphertext) is reported as +`DecryptFailed: true` instead of aborting the whole export or leaking the underlying error. + ### Other subcommands These assume a running OmniRoute server, unless noted otherwise: diff --git a/tests/unit/cli-auth-export-command.test.ts b/tests/unit/cli-auth-export-command.test.ts new file mode 100644 index 0000000000..d6cd8f2255 --- /dev/null +++ b/tests/unit/cli-auth-export-command.test.ts @@ -0,0 +1,334 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY; + +const TEST_KEY = "test-storage-encryption-key-for-auth-export"; +const PLAINTEXT_API_KEY = "sk-secret-api-key-value-12345"; +const PLAINTEXT_ACCESS_TOKEN = "oauth-access-token-value-67890"; + +function createTempDataDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-auth-export-")); +} + +interface CapturedOutput { + logs: string[]; + errors: string[]; +} + +function captureConsole(): { captured: CapturedOutput; restore: () => void } { + const originalLog = console.log; + const originalError = console.error; + const captured: CapturedOutput = { logs: [], errors: [] }; + console.log = (msg?: unknown) => { + captured.logs.push(String(msg ?? "")); + }; + console.error = (msg?: unknown) => { + captured.errors.push(String(msg ?? "")); + }; + return { + captured, + restore: () => { + console.log = originalLog; + console.error = originalError; + }, + }; +} + +async function withAuthExportEnv( + fn: (dataDir: string, dbPath: string) => Promise +): Promise { + const dataDir = createTempDataDir(); + const dbPath = path.join(dataDir, "storage.sqlite"); + process.env.DATA_DIR = dataDir; + delete process.env.STORAGE_ENCRYPTION_KEY; + + try { + new Database(dbPath).close(); + await fn(dataDir, dbPath); + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + + if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) delete process.env.STORAGE_ENCRYPTION_KEY; + else process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY; + } +} + +function seedConnection( + dbPath: string, + overrides: { apiKey?: string | null; accessToken?: string | null } = {} +) { + const db = new Database(dbPath); + const now = new Date().toISOString(); + db.prepare( + `CREATE TABLE IF NOT EXISTS provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + auth_type TEXT, + name TEXT, + email TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT, + token_expires_at TEXT, + scope TEXT, + project_id TEXT, + test_status TEXT, + error_code TEXT, + last_error TEXT, + last_error_at TEXT, + last_error_type TEXT, + last_error_source TEXT, + backoff_level INTEGER DEFAULT 0, + rate_limited_until TEXT, + health_check_interval INTEGER, + last_health_check_at TEXT, + last_tested TEXT, + api_key TEXT, + id_token TEXT, + provider_specific_data TEXT, + expires_in INTEGER, + display_name TEXT, + global_priority INTEGER, + default_model TEXT, + token_type TEXT, + consecutive_use_count INTEGER DEFAULT 0, + rate_limit_protection INTEGER DEFAULT 0, + last_used_at TEXT, + "group" TEXT, + max_concurrent INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )` + ).run(); + + const id = "conn-auth-export-test"; + db.prepare( + `INSERT INTO provider_connections (id, provider, auth_type, name, api_key, access_token, created_at, updated_at) + VALUES (@id, @provider, @authType, @name, @apiKey, @accessToken, @createdAt, @updatedAt)` + ).run({ + id, + provider: "openai", + authType: "apikey", + name: "openai", + apiKey: overrides.apiKey === undefined ? null : overrides.apiKey, + accessToken: overrides.accessToken === undefined ? null : overrides.accessToken, + createdAt: now, + updatedAt: now, + }); + db.close(); + return id; +} + +function assertNoSecretLeak(text: string, secrets: string[]) { + for (const secret of secrets) { + assert.ok( + !text.includes(secret), + `Expected output to not contain the plaintext secret value (leak found)` + ); + } +} + +test("auth export without --force never touches the DB and prints no secrets", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + seedConnection(dbPath, { apiKey: PLAINTEXT_API_KEY }); + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({}); + restore(); + + assert.equal(result, 0); + const combined = [...captured.logs, ...captured.errors].join("\n"); + assertNoSecretLeak(combined, [PLAINTEXT_API_KEY]); + }); +}); + +test("auth export with --force but no STORAGE_ENCRYPTION_KEY fails without leaking anything", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + seedConnection(dbPath, { apiKey: PLAINTEXT_API_KEY }); + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({ force: true, format: "json" }); + restore(); + + assert.equal(result, 1); + const combined = [...captured.logs, ...captured.errors].join("\n"); + assertNoSecretLeak(combined, [PLAINTEXT_API_KEY]); + assert.ok(combined.length > 0, "expected a clear error message"); + }); +}); + +test("auth export --format json decrypts and returns plaintext values for --id filter", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + const id = seedConnection(dbPath, { + apiKey: encryptCredential(PLAINTEXT_API_KEY), + accessToken: encryptCredential(PLAINTEXT_ACCESS_TOKEN), + }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({ id, force: true, format: "json" }); + restore(); + + assert.equal(result, 0); + const parsed = JSON.parse(captured.logs.join("\n")); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].apiKey, PLAINTEXT_API_KEY); + assert.equal(parsed[0].accessToken, PLAINTEXT_ACCESS_TOKEN); + assert.equal(parsed[0].apiKeyDecryptFailed, false); + assert.equal(parsed[0].accessTokenDecryptFailed, false); + }); +}); + +test("auth export tolerates malformed ciphertext in one field via a boolean flag, never the raw error", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + const id = seedConnection(dbPath, { + apiKey: "enc:v1:garbage:not:valid", + accessToken: encryptCredential(PLAINTEXT_ACCESS_TOKEN), + }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({ id, force: true, format: "json" }); + restore(); + + assert.equal(result, 0); + const parsed = JSON.parse(captured.logs.join("\n")); + assert.equal(parsed[0].apiKey, null); + assert.equal(parsed[0].apiKeyDecryptFailed, true); + // sibling field still exports correctly despite the malformed one + assert.equal(parsed[0].accessToken, PLAINTEXT_ACCESS_TOKEN); + assert.equal(parsed[0].accessTokenDecryptFailed, false); + }); +}); + +test("auth export --format env emits OMNIROUTE__= lines", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + const id = seedConnection(dbPath, { apiKey: encryptCredential(PLAINTEXT_API_KEY) }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({ id, force: true, format: "env" }); + restore(); + + assert.equal(result, 0); + const output = captured.logs.join("\n"); + assert.match(output, new RegExp(`OMNIROUTE_OPENAI_API_KEY=${PLAINTEXT_API_KEY}`)); + }); +}); + +test("auth export --out writes the file with 0600 permissions (even if it pre-existed looser)", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX file-mode assertion does not apply on Windows"); + return; + } + + await withAuthExportEnv(async (dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + const id = seedConnection(dbPath, { apiKey: encryptCredential(PLAINTEXT_API_KEY) }); + + const outFile = path.join(dataDir, "export.json"); + fs.writeFileSync(outFile, "", { mode: 0o644 }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + const { restore } = captureConsole(); + const result = await runAuthExportCommand({ id, force: true, format: "json", out: outFile }); + restore(); + + assert.equal(result, 0); + const mode = fs.statSync(outFile).mode & 0o777; + assert.equal(mode, 0o600); + const content = fs.readFileSync(outFile, "utf8"); + assert.ok(content.includes(PLAINTEXT_API_KEY)); + }); +}); + +test("auth export --id not found returns 1 and error message never echoes a decrypted value", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + seedConnection(dbPath, { apiKey: encryptCredential(PLAINTEXT_API_KEY) }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + const { captured, restore } = captureConsole(); + const result = await runAuthExportCommand({ + id: "does-not-exist", + force: true, + format: "json", + }); + restore(); + + assert.equal(result, 1); + const combined = [...captured.logs, ...captured.errors].join("\n"); + assert.ok(combined.includes("does-not-exist")); + assertNoSecretLeak(combined, [PLAINTEXT_API_KEY]); + }); +}); + +test("security regression: no plaintext secret ever leaks into stdout/stderr/error text across all paths", async () => { + await withAuthExportEnv(async (_dataDir, dbPath) => { + process.env.STORAGE_ENCRYPTION_KEY = TEST_KEY; + const { encryptCredential } = await import("../../bin/cli/encryption.mjs"); + const id = seedConnection(dbPath, { + apiKey: encryptCredential(PLAINTEXT_API_KEY), + accessToken: "enc:v1:corrupted:ciphertext:tag", + }); + + const { runAuthExportCommand } = await import("../../bin/cli/commands/auth-export.mjs"); + + // 1) dry run (no --force) + let capture = captureConsole(); + await runAuthExportCommand({}); + capture.restore(); + const dryRunText = [...capture.captured.logs, ...capture.captured.errors].join("\n"); + + // 2) missing key path + const savedKey = process.env.STORAGE_ENCRYPTION_KEY; + delete process.env.STORAGE_ENCRYPTION_KEY; + capture = captureConsole(); + await runAuthExportCommand({ force: true }); + capture.restore(); + const missingKeyText = [...capture.captured.logs, ...capture.captured.errors].join("\n"); + process.env.STORAGE_ENCRYPTION_KEY = savedKey; + + // 3) successful export with one malformed field (accessToken) + capture = captureConsole(); + await runAuthExportCommand({ id, force: true, format: "json" }); + capture.restore(); + const exportText = capture.captured.logs.join("\n"); + + // The accessToken value never decrypts (malformed), and its plaintext counterpart + // was never generated — so the only plaintext that legitimately appears anywhere + // is PLAINTEXT_API_KEY inside the successful export JSON payload itself. Error/log + // text from the dry-run and missing-key paths must never contain it. + assertNoSecretLeak(dryRunText, [PLAINTEXT_API_KEY]); + assertNoSecretLeak(missingKeyText, [PLAINTEXT_API_KEY]); + + const parsed = JSON.parse(exportText); + assert.equal(parsed[0].accessTokenDecryptFailed, true); + assert.equal(parsed[0].accessToken, null); + // the malformed raw ciphertext string itself must never appear verbatim in any + // error-labeled part of the output (it only appears as the data value, which is + // expected — but there must be no separate "error: " style leak) + assert.ok(!exportText.includes("Malformed encrypted provider credential.")); + }); +});