mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
fix(cli): secure context exports and verify provider mutations
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { writePrivateFileAtomic } from "../private-file.mjs";
|
||||
import {
|
||||
loadContexts,
|
||||
loadContextsForExport,
|
||||
saveContextsSecure,
|
||||
deleteContextCredential,
|
||||
migrateContextCredentials,
|
||||
@@ -245,16 +247,15 @@ export function registerContexts(program) {
|
||||
.command("export")
|
||||
.description("Export contexts to JSON")
|
||||
.option("--out <path>", "Output file path (default: stdout)")
|
||||
.option("--no-secrets", "Omit API keys from export")
|
||||
.option("--no-secrets", "Omit credentials from export (safe default)")
|
||||
.option("--include-secrets", "Explicitly include plaintext credentials in the export")
|
||||
.action(async (opts, cmd) => {
|
||||
const cfg = loadContexts();
|
||||
// Commander stores `--no-secrets` as `secrets === false`, never as `noSecrets`.
|
||||
const redact = opts.secrets === false || opts.noSecrets === true;
|
||||
const out = redact ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
|
||||
const includeSecrets = opts.includeSecrets === true && opts.secrets !== false;
|
||||
const cfg = await loadContextsForExport({ includeSecrets });
|
||||
const out = includeSecrets ? cfg : redactContextSecrets(cfg);
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
if (opts.out) {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(opts.out, json);
|
||||
writePrivateFileAtomic(opts.out, json);
|
||||
process.stdout.write(`Exported to ${opts.out}\n`);
|
||||
} else {
|
||||
process.stdout.write(json + "\n");
|
||||
|
||||
@@ -15,8 +15,17 @@ function credentialShape(value) {
|
||||
return { present: true, length: String(value).length };
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELD_RE =
|
||||
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
|
||||
const SENSITIVE_FIELD_SUFFIX_RE =
|
||||
/(?:^|_)(?:api_?key|access_key|secret_access_key|access_?token|refresh_?token|id_?token|auth_token|token|password|passphrase|secret|secret_key|secret_value|client_?secret|credential|authorization|private_key)$/;
|
||||
|
||||
function isSensitiveFieldName(key) {
|
||||
const normalized = String(key || "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
return SENSITIVE_FIELD_SUFFIX_RE.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact provider responses before they reach human or JSON output.
|
||||
@@ -27,7 +36,7 @@ const SENSITIVE_FIELD_RE =
|
||||
* for diagnostics; the value itself must never be printed.
|
||||
*/
|
||||
export function redactProviderResponse(value, key = "") {
|
||||
if (SENSITIVE_FIELD_RE.test(key)) {
|
||||
if (isSensitiveFieldName(key)) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return typeof value === "string" ? credentialShape(value) : "[redacted]";
|
||||
}
|
||||
@@ -58,17 +67,31 @@ export function findConnectionFromResponse(body, selector) {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return null;
|
||||
return (
|
||||
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
|
||||
rows.find((row) =>
|
||||
|
||||
const selectUnique = (matches) => {
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0];
|
||||
const candidates = matches.map((row) => String(row?.id || "<missing-id>")).join(", ");
|
||||
throw new Error(`Provider connection selector '${selector}' is ambiguous: ${candidates}`);
|
||||
};
|
||||
|
||||
const exactId = selectUnique(
|
||||
rows.filter((row) => String(row?.id || "").toLowerCase() === needle)
|
||||
);
|
||||
if (exactId) return exactId;
|
||||
const idPrefix = selectUnique(
|
||||
rows.filter((row) =>
|
||||
String(row?.id || "")
|
||||
.toLowerCase()
|
||||
.startsWith(needle)
|
||||
) ||
|
||||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
|
||||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
|
||||
null
|
||||
)
|
||||
);
|
||||
if (idPrefix) return idPrefix;
|
||||
const exactName = selectUnique(
|
||||
rows.filter((row) => String(row?.name || "").toLowerCase() === needle)
|
||||
);
|
||||
if (exactName) return exactName;
|
||||
return selectUnique(rows.filter((row) => String(row?.provider || "").toLowerCase() === needle));
|
||||
}
|
||||
|
||||
/** Build the API body without accepting management auth as a provider secret. */
|
||||
@@ -179,6 +202,48 @@ async function resolveRemoteConnection(selector, opts) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
async function readRemoteConnectionById(id, opts) {
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
|
||||
...targetOptions(opts),
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Provider mutation read-back failed: ${await readApiError(response)}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const connection = body?.connection;
|
||||
if (!connection || String(connection.id || "") !== String(id)) {
|
||||
throw new Error("Provider mutation read-back returned an unexpected connection.");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async function confirmRemoteConnectionRemoved(id, opts) {
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
|
||||
...targetOptions(opts),
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (response.status === 404) return;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provider removal read-back failed: ${await readApiError(response)}`);
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (body?.connection) {
|
||||
throw new Error("Provider removal read-back found the connection still present.");
|
||||
}
|
||||
throw new Error("Provider removal read-back returned an unexpected success response.");
|
||||
}
|
||||
|
||||
function verifyConnectionFields(connection, expected) {
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (value !== undefined && connection?.[field] !== value) {
|
||||
throw new Error(`Provider mutation read-back did not persist field '${field}'.`);
|
||||
}
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function runProviderAddCommand(provider, opts = {}) {
|
||||
const normalized = String(provider || "").trim();
|
||||
if (!normalized) {
|
||||
@@ -241,9 +306,18 @@ export async function runProviderAddCommand(provider, opts = {}) {
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const created = body?.connection;
|
||||
if (!created?.id) throw new Error("Provider create response did not include a connection id.");
|
||||
const verified = verifyConnectionFields(await readRemoteConnectionById(created.id, opts), {
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
defaultModel: payload.defaultModel,
|
||||
priority: payload.priority,
|
||||
});
|
||||
if (!opts.silent) {
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
|
||||
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
|
||||
} else printSuccess(`Added provider connection '${verified.name || payload.name}'.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
@@ -271,6 +345,29 @@ export async function runProviderImportCommand(file, opts = {}) {
|
||||
printError("Provider import file contains no entries.");
|
||||
return 2;
|
||||
}
|
||||
const existingByProviderAndName = new Map();
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
const response = await listRemoteConnections(opts);
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const connections = Array.isArray(body?.connections) ? body.connections : [];
|
||||
for (const connection of connections) {
|
||||
const key = `${String(connection?.provider || "").toLowerCase()}\0${String(
|
||||
connection?.name || connection?.provider || ""
|
||||
).toLowerCase()}`;
|
||||
if (!existingByProviderAndName.has(key)) {
|
||||
existingByProviderAndName.set(key, connection);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object" || !entry.provider) {
|
||||
@@ -278,16 +375,48 @@ export async function runProviderImportCommand(file, opts = {}) {
|
||||
if (!opts.continueOnError) break;
|
||||
continue;
|
||||
}
|
||||
const code = await runProviderAddCommand(entry.provider, {
|
||||
...opts,
|
||||
...entry,
|
||||
const provider = String(entry.provider).trim();
|
||||
const name = String(entry.name || provider).trim();
|
||||
const identityKey = `${provider.toLowerCase()}\0${name.toLowerCase()}`;
|
||||
const existing = existingByProviderAndName.get(identityKey);
|
||||
if (existing) {
|
||||
const result = {
|
||||
provider,
|
||||
name,
|
||||
ok: true,
|
||||
status: "skipped_existing",
|
||||
connectionId: existing.id,
|
||||
};
|
||||
results.push(result);
|
||||
if (!opts.json) printInfo(`Skipped existing provider connection '${name}'.`);
|
||||
continue;
|
||||
}
|
||||
// Import files contain provider data, never command/control-plane options.
|
||||
// Keep the management target, context and authentication exclusively from
|
||||
// the CLI invocation so an imported document cannot redirect credentials.
|
||||
const importedProviderOptions = {
|
||||
name: entry.name,
|
||||
defaultModel: entry.defaultModel,
|
||||
priority: entry.priority,
|
||||
providerSpecificData: entry.providerSpecificData,
|
||||
credential: entry.apiKey ?? entry.credential,
|
||||
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
|
||||
};
|
||||
const code = await runProviderAddCommand(provider, {
|
||||
...opts,
|
||||
...importedProviderOptions,
|
||||
dryRun: opts.dryRun,
|
||||
yes: true,
|
||||
silent: true,
|
||||
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
|
||||
});
|
||||
results.push({ provider: entry.provider, ok: code === 0, code });
|
||||
results.push({
|
||||
provider,
|
||||
name,
|
||||
ok: code === 0,
|
||||
code,
|
||||
status: code === 0 ? "created" : "error",
|
||||
});
|
||||
if (code === 0) existingByProviderAndName.set(identityKey, { provider, name });
|
||||
if (code !== 0 && !opts.continueOnError) break;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
|
||||
@@ -340,6 +469,7 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
await confirmRemoteConnectionRemoved(connection.id, opts);
|
||||
if (opts.json)
|
||||
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
|
||||
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
|
||||
@@ -351,12 +481,24 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
|
||||
}
|
||||
|
||||
export async function runProviderEditCommand(selector, opts = {}) {
|
||||
if (opts.active === true && opts.inactive === true) {
|
||||
printError("--active and --inactive cannot be used together.");
|
||||
return 2;
|
||||
}
|
||||
let parsedPriority;
|
||||
if (opts.priority !== undefined) {
|
||||
parsedPriority = Number(opts.priority);
|
||||
if (!Number.isInteger(parsedPriority) || parsedPriority < 1) {
|
||||
printError("--priority must be a positive integer.");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
const body = {};
|
||||
if (opts.name !== undefined) body.name = opts.name;
|
||||
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
|
||||
if (opts.priority !== undefined) body.priority = Number(opts.priority);
|
||||
if (parsedPriority !== undefined) body.priority = parsedPriority;
|
||||
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
|
||||
if (opts.inactive !== undefined) body.isActive = false;
|
||||
const credential = await resolveProviderCredential(opts, { prompt: false });
|
||||
@@ -388,9 +530,16 @@ export async function runProviderEditCommand(selector, opts = {}) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
|
||||
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
|
||||
await response.json().catch(() => ({}));
|
||||
const expected = { ...body };
|
||||
delete expected.apiKey;
|
||||
const verified = verifyConnectionFields(
|
||||
await readRemoteConnectionById(connection.id, opts),
|
||||
expected
|
||||
);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
|
||||
} else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
import { writePrivateFileAtomic } from "./private-file.mjs";
|
||||
|
||||
const CONFIG_VERSION = 1;
|
||||
const KEYCHAIN_SERVICE = "omniroute-cli";
|
||||
@@ -124,17 +125,44 @@ export function loadContexts() {
|
||||
return readConfigFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load contexts for an explicit export operation.
|
||||
*
|
||||
* The persisted file intentionally contains only `credentialRef` for
|
||||
* keychain-backed contexts. Secret-bearing exports therefore have to hydrate
|
||||
* every referenced credential first. Fail closed when any reference cannot be
|
||||
* resolved so `--include-secrets` never produces a silently incomplete backup.
|
||||
*/
|
||||
export async function loadContextsForExport({ includeSecrets = false } = {}) {
|
||||
const cfg = readConfigFile();
|
||||
if (!includeSecrets) return cfg;
|
||||
|
||||
await hydrateCredentialCache(cfg);
|
||||
const out = JSON.parse(JSON.stringify(cfg));
|
||||
const contexts = out.contexts || out.profiles || {};
|
||||
const unresolved = [];
|
||||
for (const [name, context] of Object.entries(contexts)) {
|
||||
if (!context || typeof context !== "object" || !context.credentialRef) continue;
|
||||
const credential = credentialForContext(context);
|
||||
if (!credential) {
|
||||
unresolved.push(name);
|
||||
continue;
|
||||
}
|
||||
Object.assign(context, credential);
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
throw new Error(`Cannot include keychain credentials for context(s): ${unresolved.join(", ")}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous compatibility writer. New credential-bearing code should use
|
||||
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
|
||||
*/
|
||||
export function saveContexts(cfg) {
|
||||
const path = configPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {}
|
||||
writePrivateFileAtomic(path, JSON.stringify(cfg, null, 2));
|
||||
}
|
||||
|
||||
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
|
||||
|
||||
49
bin/cli/private-file.mjs
Normal file
49
bin/cli/private-file.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
fchmodSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Atomically replace a credential-bearing file without following a destination
|
||||
* symlink. The temporary file is private from its first filesystem operation.
|
||||
*/
|
||||
export function writePrivateFileAtomic(path, content, { mode = 0o600 } = {}) {
|
||||
const directory = dirname(path);
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const suffix = randomBytes(8).toString("hex");
|
||||
const temporary = join(directory, `.${basename(path)}.${process.pid}.${suffix}.tmp`);
|
||||
let descriptor;
|
||||
|
||||
try {
|
||||
descriptor = openSync(
|
||||
temporary,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
||||
mode
|
||||
);
|
||||
writeFileSync(descriptor, String(content), "utf8");
|
||||
fchmodSync(descriptor, mode);
|
||||
fsyncSync(descriptor);
|
||||
closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
renameSync(temporary, path);
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
try {
|
||||
closeSync(descriptor);
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
unlinkSync(temporary);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ is retained for controlled local use. `providers remove` requires `--yes` on a
|
||||
non-interactive terminal, and all five commands honor the active context or the
|
||||
global `--base-url`/`--api-key` options.
|
||||
|
||||
Provider selectors reject ambiguous ID prefixes, names or provider names; use a
|
||||
full connection ID when several connections match. Create and edit commands read
|
||||
the saved connection back, and removal verifies that it is no longer readable.
|
||||
An import skips an existing provider/name pair. Imported entries cannot override
|
||||
the management endpoint, context or management credentials supplied to the CLI.
|
||||
|
||||
For the one-time, hand-written base setup of the two richest integrations, see the
|
||||
per-tool deep dives:
|
||||
|
||||
|
||||
@@ -393,19 +393,23 @@ omniroute contexts remove stg --yes
|
||||
> revoke the token on the server with `omniroute tokens revoke <id>` to actually
|
||||
> kill access.
|
||||
|
||||
**Export / import** contexts (e.g. to move them between machines). New contexts persist
|
||||
only a keychain reference; credentials are not copied into the export when the OS
|
||||
keychain is available:
|
||||
**Export / import** contexts (e.g. to move them between machines). Exports omit
|
||||
credentials by default, including credentials stored by the file fallback. Use
|
||||
`--include-secrets` explicitly when a portable credential-bearing backup is needed:
|
||||
|
||||
```bash
|
||||
omniroute contexts export --out contexts.json # default: stdout
|
||||
omniroute contexts export --out contexts.json # redacted; default destination: stdout
|
||||
omniroute contexts export --include-secrets --out private-contexts.json
|
||||
omniroute contexts import contexts.json # overwrite; --merge to keep existing
|
||||
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
|
||||
```
|
||||
|
||||
On headless systems without a usable OS keychain, the CLI falls back to
|
||||
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
|
||||
that fallback (and any legacy config before migration) as secret material.
|
||||
`--include-secrets` resolves keychain references before exporting and fails if any
|
||||
referenced credential cannot be read. `--no-secrets` always takes precedence.
|
||||
Export files are written atomically with mode `0600`. Treat an explicit
|
||||
secret-bearing export as secret material. On headless systems without a usable OS
|
||||
keychain, the CLI falls back to `config.json` with mode `0600` and prints a
|
||||
one-time warning; a default export remains redacted in this mode.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -49,6 +57,25 @@ test("saveContexts persiste e loadContexts relê", async () => {
|
||||
assert.equal(cfg2.contexts.test?.baseUrl, "http://test:9999");
|
||||
});
|
||||
|
||||
test("saveContexts replaces a hostile symlink atomically with a private regular file", async () => {
|
||||
const { configPath, saveContexts } = await import("../../bin/cli/contexts.mjs");
|
||||
const path = configPath();
|
||||
const victim = join(tmpDir, "victim.json");
|
||||
rmSync(path, { force: true });
|
||||
writeFileSync(victim, "do-not-overwrite");
|
||||
symlinkSync(victim, path);
|
||||
|
||||
saveContexts({
|
||||
version: 1,
|
||||
currentContext: "default",
|
||||
contexts: { default: { baseUrl: "http://localhost:20128", apiKey: null } },
|
||||
});
|
||||
|
||||
assert.equal(readFileSync(victim, "utf8"), "do-not-overwrite");
|
||||
assert.equal(lstatSync(path).isSymbolicLink(), false);
|
||||
assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("resolveActiveContext retorna contexto ativo", async () => {
|
||||
const { resolveActiveContext, loadContexts, saveContexts } =
|
||||
await import("../../bin/cli/contexts.mjs");
|
||||
@@ -177,3 +204,156 @@ test("registerContexts registers the singular `context` alias", async () => {
|
||||
registerContexts(fakeProgram);
|
||||
assert.equal(aliasName, "context");
|
||||
});
|
||||
|
||||
test("context export --no-secrets overrides explicit inclusion through the real command", async () => {
|
||||
const { saveContexts } = await import("../../bin/cli/contexts.mjs");
|
||||
const { createProgram } = await import("../../bin/cli/program.mjs");
|
||||
saveContexts({
|
||||
version: 1,
|
||||
currentContext: "remote",
|
||||
contexts: {
|
||||
remote: {
|
||||
baseUrl: "https://remote.example.com",
|
||||
accessToken: "oma-command-secret",
|
||||
apiKey: "sk-command-secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
const chunks: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
chunks.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
await createProgram().parseAsync([
|
||||
"node",
|
||||
"omniroute",
|
||||
"context",
|
||||
"export",
|
||||
"--no-secrets",
|
||||
"--include-secrets",
|
||||
]);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
|
||||
const exported = chunks.join("");
|
||||
assert.ok(!exported.includes("oma-command-secret"));
|
||||
assert.ok(!exported.includes("sk-command-secret"));
|
||||
assert.equal(JSON.parse(exported).contexts.remote.apiKey, null);
|
||||
});
|
||||
|
||||
test("context export --include-secrets resolves keychain-backed credentials", async () => {
|
||||
const { loadContexts, saveContextsSecure, setContextKeychainBackendForTests } =
|
||||
await import("../../bin/cli/contexts.mjs");
|
||||
const { createProgram } = await import("../../bin/cli/program.mjs");
|
||||
const entries = new Map<string, string>();
|
||||
const fakeKeychain = {
|
||||
async getPassword(_service: string, account: string) {
|
||||
return entries.get(account) || null;
|
||||
},
|
||||
async setPassword(_service: string, account: string, value: string) {
|
||||
entries.set(account, value);
|
||||
},
|
||||
async deletePassword(_service: string, account: string) {
|
||||
entries.delete(account);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await setContextKeychainBackendForTests(fakeKeychain);
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts.keychainExport = {
|
||||
baseUrl: "https://keychain.example.com",
|
||||
accessToken: "oma-keychain-export",
|
||||
apiKey: "sk-keychain-export",
|
||||
};
|
||||
await saveContextsSecure(cfg);
|
||||
|
||||
const chunks: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
chunks.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
await createProgram().parseAsync([
|
||||
"node",
|
||||
"omniroute",
|
||||
"context",
|
||||
"export",
|
||||
"--include-secrets",
|
||||
]);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
await setContextKeychainBackendForTests(null);
|
||||
}
|
||||
|
||||
const exported = JSON.parse(chunks.join(""));
|
||||
assert.equal(exported.contexts.keychainExport.accessToken, "oma-keychain-export");
|
||||
assert.equal(exported.contexts.keychainExport.apiKey, "sk-keychain-export");
|
||||
});
|
||||
|
||||
test("secret-bearing context export fails closed when a keychain reference is unavailable", async () => {
|
||||
const { loadContextsForExport, saveContexts, setContextKeychainBackendForTests } =
|
||||
await import("../../bin/cli/contexts.mjs");
|
||||
await setContextKeychainBackendForTests(null);
|
||||
saveContexts({
|
||||
version: 1,
|
||||
currentContext: "missingKeychain",
|
||||
contexts: {
|
||||
missingKeychain: {
|
||||
baseUrl: "https://keychain.example.com",
|
||||
credentialRef: "omniroute-cli:context:missingKeychain",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
loadContextsForExport({ includeSecrets: true }),
|
||||
/Cannot include keychain credentials for context\(s\): missingKeychain/
|
||||
);
|
||||
});
|
||||
|
||||
test("context export defaults to a redacted atomic private output file", async () => {
|
||||
const { saveContexts } = await import("../../bin/cli/contexts.mjs");
|
||||
const { createProgram } = await import("../../bin/cli/program.mjs");
|
||||
saveContexts({
|
||||
version: 1,
|
||||
currentContext: "remote",
|
||||
contexts: {
|
||||
remote: {
|
||||
baseUrl: "https://remote.example.com",
|
||||
accessToken: "oma-file-secret",
|
||||
apiKey: "sk-file-secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
const victim = join(tmpDir, "export-victim.json");
|
||||
const output = join(tmpDir, "contexts-export.json");
|
||||
rmSync(output, { force: true });
|
||||
writeFileSync(victim, "do-not-overwrite");
|
||||
symlinkSync(victim, output);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
await createProgram().parseAsync(["node", "omniroute", "context", "export", "--out", output]);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
|
||||
assert.equal(readFileSync(victim, "utf8"), "do-not-overwrite");
|
||||
assert.equal(lstatSync(output).isSymbolicLink(), false);
|
||||
assert.equal(statSync(output).mode & 0o777, 0o600);
|
||||
const exported = readFileSync(output, "utf8");
|
||||
assert.ok(!exported.includes("oma-file-secret"));
|
||||
assert.ok(!exported.includes("sk-file-secret"));
|
||||
assert.match(stdout.join(""), /Exported to/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
buildProviderPayload,
|
||||
@@ -7,8 +11,18 @@ import {
|
||||
redactProviderResponse,
|
||||
resolveProviderCredential,
|
||||
runProviderAddCommand,
|
||||
runProviderEditCommand,
|
||||
runProviderImportCommand,
|
||||
runProviderRemoveCommand,
|
||||
} from "../../../bin/cli/commands/provider-crud.mjs";
|
||||
|
||||
async function listen(server: ReturnType<typeof createServer>): Promise<string> {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
test("provider payload separates management auth from provider credential", () => {
|
||||
const payload = buildProviderPayload(
|
||||
"glm",
|
||||
@@ -47,6 +61,18 @@ test("provider selector resolves id, prefix, name, and provider", () => {
|
||||
assert.equal(findConnectionFromResponse(body, "missing"), null);
|
||||
});
|
||||
|
||||
test("provider selector rejects ambiguous prefixes instead of mutating the first match", () => {
|
||||
const body = {
|
||||
connections: [
|
||||
{ id: "abc-123", name: "Work GLM", provider: "glm" },
|
||||
{ id: "abc-456", name: "Backup GLM", provider: "glm" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.throws(() => findConnectionFromResponse(body, "abc"), /ambiguous.*abc-123.*abc-456/i);
|
||||
assert.throws(() => findConnectionFromResponse(body, "glm"), /ambiguous.*abc-123.*abc-456/i);
|
||||
});
|
||||
|
||||
test("provider credential can be resolved from a validated environment name", async () => {
|
||||
const previous = process.env.TEST_PROVIDER_SECRET;
|
||||
process.env.TEST_PROVIDER_SECRET = "secret-from-env";
|
||||
@@ -91,6 +117,11 @@ test("provider JSON output redacts raw credentials recursively", () => {
|
||||
apiKey: "provider-secret",
|
||||
providerSpecificData: { client_secret: "oauth-secret" },
|
||||
credentialRef: "omniroute-cli:context:remote",
|
||||
awsSecretAccessKey: "aws-secret",
|
||||
privateKey: "private-key-material",
|
||||
secret_key: "snake-secret",
|
||||
APIKEY: "compact-key",
|
||||
clientsecret: "compact-secret",
|
||||
},
|
||||
token: "management-secret",
|
||||
});
|
||||
@@ -101,6 +132,11 @@ test("provider JSON output redacts raw credentials recursively", () => {
|
||||
apiKey: { present: true, length: 15 },
|
||||
providerSpecificData: { client_secret: { present: true, length: 12 } },
|
||||
credentialRef: "omniroute-cli:context:remote",
|
||||
awsSecretAccessKey: { present: true, length: 10 },
|
||||
privateKey: { present: true, length: 20 },
|
||||
secret_key: { present: true, length: 12 },
|
||||
APIKEY: { present: true, length: 11 },
|
||||
clientsecret: { present: true, length: 14 },
|
||||
},
|
||||
token: { present: true, length: 17 },
|
||||
});
|
||||
@@ -134,3 +170,256 @@ test("provider add dry-run redacts provider-specific secrets", async () => {
|
||||
assert.ok(!serialized.includes("oauth-secret"));
|
||||
assert.match(serialized, /client_secret/);
|
||||
});
|
||||
|
||||
test("provider import cannot override the management target or authentication", async () => {
|
||||
const requests: Array<{
|
||||
method?: string;
|
||||
url?: string;
|
||||
authorization?: string;
|
||||
body?: unknown;
|
||||
}> = [];
|
||||
let attackerRequests = 0;
|
||||
const managementServer = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => {
|
||||
requests.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined,
|
||||
});
|
||||
response.writeHead(request.method === "POST" ? 201 : 200, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify({ connection: { id: "conn-1", provider: "glm", name: "work" } }));
|
||||
});
|
||||
});
|
||||
const attackerServer = createServer((_request, response) => {
|
||||
attackerRequests += 1;
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ connection: { id: "attacker" } }));
|
||||
});
|
||||
const directory = mkdtempSync(join(tmpdir(), "omniroute-provider-import-"));
|
||||
|
||||
try {
|
||||
const managementUrl = await listen(managementServer);
|
||||
const attackerUrl = await listen(attackerServer);
|
||||
const file = join(directory, "providers.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
credential: "secondary-provider-secret",
|
||||
baseUrl: attackerUrl,
|
||||
apiKey: "imported-provider-secret",
|
||||
context: "attacker-context",
|
||||
oauth: false,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await runProviderImportCommand(file, {
|
||||
baseUrl: managementUrl,
|
||||
apiKey: "management-token",
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert.equal(attackerRequests, 0);
|
||||
assert.equal(requests.length, 3);
|
||||
assert.deepEqual(
|
||||
requests.map(({ method, url }) => ({ method, url })),
|
||||
[
|
||||
{ method: "GET", url: "/api/providers?limit=5000" },
|
||||
{ method: "POST", url: "/api/providers" },
|
||||
{ method: "GET", url: "/api/providers/conn-1" },
|
||||
]
|
||||
);
|
||||
assert.equal(requests[0]?.authorization, "Bearer management-token");
|
||||
assert.equal(requests[1]?.authorization, "Bearer management-token");
|
||||
assert.equal(requests[2]?.authorization, "Bearer management-token");
|
||||
assert.deepEqual(requests[1]?.body, {
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
apiKey: "imported-provider-secret",
|
||||
});
|
||||
} finally {
|
||||
managementServer.close();
|
||||
attackerServer.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider edit rejects contradictory state flags and invalid priority before networking", async () => {
|
||||
const unreachable = "http://127.0.0.1:1";
|
||||
|
||||
assert.equal(
|
||||
await runProviderEditCommand("conn-1", {
|
||||
baseUrl: unreachable,
|
||||
active: true,
|
||||
inactive: true,
|
||||
}),
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
await runProviderEditCommand("conn-1", {
|
||||
baseUrl: unreachable,
|
||||
priority: 0,
|
||||
}),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
test("provider import is idempotent and reports an existing connection without mutating", async () => {
|
||||
let mutations = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.method !== "GET") mutations += 1;
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
connections: [{ id: "conn-1", provider: "glm", name: "work" }],
|
||||
})
|
||||
);
|
||||
});
|
||||
const directory = mkdtempSync(join(tmpdir(), "omniroute-provider-idempotent-"));
|
||||
const output: string[] = [];
|
||||
const originalLog = console.log;
|
||||
|
||||
try {
|
||||
const baseUrl = await listen(server);
|
||||
const file = join(directory, "providers.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ provider: "glm", name: "work", apiKey: "provider-secret" })
|
||||
);
|
||||
console.log = (...args: unknown[]) => output.push(args.join(" "));
|
||||
|
||||
assert.equal(
|
||||
await runProviderImportCommand(file, {
|
||||
baseUrl,
|
||||
apiKey: "management-token",
|
||||
json: true,
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert.equal(mutations, 0);
|
||||
const result = JSON.parse(output.join("\n"));
|
||||
assert.deepEqual(result.results, [
|
||||
{
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
ok: true,
|
||||
status: "skipped_existing",
|
||||
connectionId: "conn-1",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
server.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider edit confirms the persisted state with a read-back", async () => {
|
||||
const methods: string[] = [];
|
||||
let connection = {
|
||||
id: "conn-1",
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
priority: 1,
|
||||
isActive: false,
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => {
|
||||
methods.push(`${request.method} ${request.url}`);
|
||||
if (request.method === "PUT") {
|
||||
connection = { ...connection, ...JSON.parse(Buffer.concat(chunks).toString("utf8")) };
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
request.url === "/api/providers?limit=5000"
|
||||
? JSON.stringify({ connections: [connection] })
|
||||
: JSON.stringify({ connection })
|
||||
);
|
||||
});
|
||||
});
|
||||
const output: string[] = [];
|
||||
const originalLog = console.log;
|
||||
|
||||
try {
|
||||
const baseUrl = await listen(server);
|
||||
console.log = (...args: unknown[]) => output.push(args.join(" "));
|
||||
assert.equal(
|
||||
await runProviderEditCommand("conn-1", {
|
||||
baseUrl,
|
||||
apiKey: "management-token",
|
||||
name: "renamed",
|
||||
priority: 3,
|
||||
active: true,
|
||||
json: true,
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert.deepEqual(methods, [
|
||||
"GET /api/providers?limit=5000",
|
||||
"PUT /api/providers/conn-1",
|
||||
"GET /api/providers/conn-1",
|
||||
]);
|
||||
const result = JSON.parse(output.join("\n"));
|
||||
assert.equal(result.connection.name, "renamed");
|
||||
assert.equal(result.connection.priority, 3);
|
||||
assert.equal(result.connection.isActive, true);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider removal confirms that the connection is no longer readable", async () => {
|
||||
const methods: string[] = [];
|
||||
let removed = false;
|
||||
const connection = { id: "conn-1", provider: "glm", name: "work" };
|
||||
const server = createServer((request, response) => {
|
||||
methods.push(`${request.method} ${request.url}`);
|
||||
if (request.method === "DELETE") removed = true;
|
||||
if (request.method === "GET" && request.url === "/api/providers/conn-1" && removed) {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Connection not found" }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
request.url === "/api/providers?limit=5000"
|
||||
? JSON.stringify({ connections: [connection] })
|
||||
: JSON.stringify({ removed: true })
|
||||
);
|
||||
});
|
||||
const output: string[] = [];
|
||||
const originalLog = console.log;
|
||||
|
||||
try {
|
||||
const baseUrl = await listen(server);
|
||||
console.log = (...args: unknown[]) => output.push(args.join(" "));
|
||||
assert.equal(
|
||||
await runProviderRemoveCommand("conn-1", {
|
||||
baseUrl,
|
||||
apiKey: "management-token",
|
||||
yes: true,
|
||||
json: true,
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert.deepEqual(methods, [
|
||||
"GET /api/providers?limit=5000",
|
||||
"DELETE /api/providers/conn-1",
|
||||
"GET /api/providers/conn-1",
|
||||
]);
|
||||
assert.equal(JSON.parse(output.join("\n")).removed.id, "conn-1");
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user