mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
This commit is contained in:
committed by
GitHub
parent
63716adc0b
commit
7951b60bc3
163
bin/cli/commands/auth-export.mjs
Normal file
163
bin/cli/commands/auth-export.mjs
Normal file
@@ -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 <id>", t("authExport.idOpt"))
|
||||
.option("--format <format>", t("authExport.formatOpt"), "json")
|
||||
.option("--out <file>", 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
changelog.d/features/6683-cli-auth-export.md
Normal file
1
changelog.d/features/6683-cli-auth-export.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(cli):** new `omniroute auth export` command dumps DECRYPTED provider credentials (`apiKey`/`accessToken`/`refreshToken`/`idToken`) for one connection (`--id <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 <file>` writes with `0600` permissions, and per-field decrypt failures surface as a `<field>DecryptFailed` boolean instead of aborting the export or leaking the caught error text (#6683)
|
||||
@@ -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 <id> # Export only the matching connection
|
||||
omniroute auth export --force --format env # Emit OMNIROUTE_<PROVIDER>_<FIELD>=<value> 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
|
||||
`<field>DecryptFailed: true` instead of aborting the whole export or leaking the underlying error.
|
||||
|
||||
### Other subcommands
|
||||
|
||||
These assume a running OmniRoute server, unless noted otherwise:
|
||||
|
||||
334
tests/unit/cli-auth-export-command.test.ts
Normal file
334
tests/unit/cli-auth-export-command.test.ts
Normal file
@@ -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<void>
|
||||
): Promise<void> {
|
||||
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_<PROVIDER>_<FIELD>=<value> 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: <ciphertext>" style leak)
|
||||
assert.ok(!exportText.includes("Malformed encrypted provider credential."));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user