From 593659f6521dbd57cec9e45069cf15635acc7346 Mon Sep 17 00:00:00 2001 From: StefanSa <6105075+StefanSa@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:15:36 +0200 Subject: [PATCH] Add Bitwarden Secrets Manager provider via external bws (#1416) * add external bws Bitwarden secrets provider * add self-hosted Bitwarden server support --- README.md | 23 ++ src/lib/server/secretproviders/bitwarden.ts | 370 ++++++++++++++++++ src/lib/server/secretproviders/index.ts | 4 +- src/lib/server/secretproviders/shared.ts | 11 +- .../settings/secrets/ProviderModal.svelte | 17 + tests/secret-provider-bitwarden.test.ts | 285 ++++++++++++++ 6 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/secretproviders/bitwarden.ts create mode 100644 tests/secret-provider-bitwarden.test.ts diff --git a/README.md b/README.md index 32be083..253526a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,29 @@ Dockhand is a modern, efficient Docker management application providing real-tim - **Database**: SQLite or PostgreSQL via Drizzle ORM - **Docker**: direct docker API calls. +## Bitwarden Secrets Manager + +The Bitwarden Secrets Manager provider requires an official `bws` client supplied by the +operator. Dockhand does not include, download, or redistribute `bws`. By default the provider +executes `/usr/local/bin/bws`; the process-level `DOCKHAND_BWS_PATH` setting may override this +with another absolute path. + +Configure the Machine Account access token in Dockhand's secret-provider settings and set the +stack's `DOCKHAND_SECRET_SELECTOR` to the Bitwarden Project UUID. + +For Bitwarden EU or a self-hosted instance, also configure the optional server URL. Dockhand +applies its provider URL-safety checks and uses `bws config server-base` in an isolated +temporary profile. + +For the official Dockhand container, mount the operator-managed executable read-only: + +```yaml +volumes: + - /opt/dockhand-tools/bws:/usr/local/bin/bws:ro +``` + +Obtaining, verifying, updating, and licensing `bws` remains the operator's responsibility. + ## Screenshots diff --git a/src/lib/server/secretproviders/bitwarden.ts b/src/lib/server/secretproviders/bitwarden.ts new file mode 100644 index 0000000..7b35b2a --- /dev/null +++ b/src/lib/server/secretproviders/bitwarden.ts @@ -0,0 +1,370 @@ +/** + * Bitwarden Secrets Manager provider. + * + * This is deliberately only an adapter around an operator-provided official + * `bws` executable. Dockhand does not distribute the client and does not + * implement Bitwarden's API or cryptography. The provider bulk-loads one + * Project selected by UUID and optionally targets an EU or self-hosted server. + */ + +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import type { BitwardenConfig, SecretProvider, TestConnectionResult } from './shared'; +import { UnsupportedOperationError, assertSafeProviderHost } from './shared'; + +const DEFAULT_BWS_PATH = '/usr/local/bin/bws'; +const VERSION_TIMEOUT_MS = 5_000; +const COMMAND_TIMEOUT_MS = 30_000; +const KILL_GRACE_MS = 2_000; +const VERSION_OUTPUT_LIMIT = 4 * 1024; +const TEST_OUTPUT_LIMIT = 2 * 1024 * 1024; +const SECRET_OUTPUT_LIMIT = 10 * 1024 * 1024; +const STDERR_OUTPUT_LIMIT = 64 * 1024; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +const CHILD_ENV_ALLOWLIST = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR' +] as const; + +class BwsAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = 'BwsAdapterError'; + } +} + +interface CommandLimits { + timeoutMs: number; + stdoutBytes: number; + stderrBytes: number; + accessToken?: string; + serverUrl?: string; +} + +function executablePath(): string { + const override = process.env.DOCKHAND_BWS_PATH?.trim(); + if (!override) return DEFAULT_BWS_PATH; + if (!isAbsolute(override)) { + throw new BwsAdapterError('Bitwarden bws executable path must be absolute'); + } + return override; +} + +function childEnvironment(stateDir: string, accessToken?: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + HOME: stateDir, + XDG_CONFIG_HOME: stateDir + }; + for (const key of CHILD_ENV_ALLOWLIST) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + if (accessToken !== undefined) env.BWS_ACCESS_TOKEN = accessToken; + return env; +} + +function spawnFailure(error: unknown): BwsAdapterError { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code === 'ENOENT') return new BwsAdapterError('Bitwarden bws executable was not found'); + if (code === 'EACCES') return new BwsAdapterError('Bitwarden bws executable is not executable'); + return new BwsAdapterError('Bitwarden bws executable could not be started'); +} + +/** Execute one bws process without a shell while bounding its lifetime and output. */ +async function executeBws( + stateDir: string, + args: string[], + limits: CommandLimits +): Promise { + const stdoutChunks: Buffer[] = []; + try { + return await new Promise((resolve, reject) => { + let stdoutBytes = 0; + let stderrBytes = 0; + let failure: BwsAdapterError | undefined; + let settled = false; + let killTimer: ReturnType | undefined; + + const child = spawn(executablePath(), args, { + env: childEnvironment(stateDir, limits.accessToken), + stdio: ['ignore', 'pipe', 'pipe'], + shell: false + }); + + const terminate = (error: BwsAdapterError) => { + if (failure) return; + failure = error; + try { + child.kill('SIGTERM'); + } catch { + // The close/error handler below still settles the command. + } + killTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // The close/error handler below still settles the command. + } + } + }, KILL_GRACE_MS); + }; + + const timeout = setTimeout( + () => terminate(new BwsAdapterError('Bitwarden bws command timed out')), + limits.timeoutMs + ); + + child.stdout?.on('data', (chunk: Buffer | string) => { + if (failure) return; + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += data.length; + if (stdoutBytes > limits.stdoutBytes) { + terminate(new BwsAdapterError('Bitwarden bws command exceeded the stdout limit')); + return; + } + stdoutChunks.push(Buffer.from(data)); + }); + + // stderr is deliberately never retained: only its byte count is observed. + child.stderr?.on('data', (chunk: Buffer | string) => { + if (failure) return; + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > limits.stderrBytes) { + terminate(new BwsAdapterError('Bitwarden bws command exceeded the stderr limit')); + } + }); + + child.once('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + reject(failure ?? spawnFailure(error)); + }); + + child.once('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + if (failure) { + reject(failure); + return; + } + if (code !== 0) { + reject(new BwsAdapterError('Bitwarden bws command failed')); + return; + } + resolve(Buffer.concat(stdoutChunks, stdoutBytes)); + }); + }); + } finally { + for (const chunk of stdoutChunks) chunk.fill(0); + } +} + +/** Execute bws in an isolated profile, configuring a non-default server when requested. */ +async function runBws(args: string[], limits: CommandLimits): Promise { + let stateDir: string | undefined; + try { + stateDir = await mkdtemp(join(tmpdir(), 'dockhand-bws-')); + await chmod(stateDir, 0o700); + } catch { + if (stateDir) await rm(stateDir, { recursive: true, force: true }).catch(() => undefined); + throw new BwsAdapterError('Bitwarden bws temporary state could not be created'); + } + + try { + if (limits.serverUrl) { + const configOutput = await executeBws( + stateDir, + ['config', 'server-base', limits.serverUrl], + { + timeoutMs: VERSION_TIMEOUT_MS, + stdoutBytes: VERSION_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + } + ); + configOutput.fill(0); + } + return await executeBws(stateDir, args, limits); + } finally { + try { + await rm(stateDir, { recursive: true, force: true }); + } catch { + throw new BwsAdapterError('Bitwarden bws temporary state could not be removed'); + } + } +} + +async function runJsonArray(args: string[], limits: CommandLimits): Promise { + let output: Buffer | undefined; + try { + output = await runBws(args, limits); + let parsed: unknown; + try { + parsed = JSON.parse(output.toString('utf8')); + } catch { + throw new BwsAdapterError('Bitwarden bws returned invalid JSON'); + } + if (!Array.isArray(parsed)) { + throw new BwsAdapterError('Bitwarden bws returned an invalid JSON response'); + } + return parsed; + } finally { + output?.fill(0); + } +} + +async function assertSupportedVersion(): Promise { + let output: Buffer | undefined; + try { + output = await runBws(['--version'], { + timeoutMs: VERSION_TIMEOUT_MS, + stdoutBytes: VERSION_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + }); + const match = /^bws\s+(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*$/.exec( + output.toString('utf8') + ); + const minor = match ? Number(match[2]) : -1; + const patch = match ? Number(match[3]) : -1; + const isAtLeastStable210 = minor > 1 || (minor === 1 && (patch > 0 || !match?.[4])); + if (!match || Number(match[1]) !== 2 || !isAtLeastStable210) { + throw new BwsAdapterError('Bitwarden bws version must be >=2.1.0 and <3.0.0'); + } + } finally { + output?.fill(0); + } +} + +function accessToken(config: BitwardenConfig): string { + const token = typeof config?.token === 'string' ? config.token.trim() : ''; + if (!token) throw new BwsAdapterError('Bitwarden Machine Account access token is empty'); + return token; +} + +function serverUrl(config: BitwardenConfig): string | undefined { + const url = typeof config?.serverUrl === 'string' ? config.serverUrl.trim() : ''; + if (!url) return undefined; + try { + assertSafeProviderHost(url, 'Bitwarden Secrets Manager'); + } catch (error: unknown) { + throw new BwsAdapterError( + error instanceof Error ? error.message : 'Bitwarden Secrets Manager: host not allowed' + ); + } + return url.replace(/\/+$/, ''); +} + +function projectId(selector: string): string { + const id = typeof selector === 'string' ? selector.trim() : ''; + if (!UUID_RE.test(id)) { + throw new BwsAdapterError('Bitwarden Project selector must be a valid UUID'); + } + return id; +} + +function sanitizedError(error: unknown): string { + return error instanceof BwsAdapterError + ? error.message + : 'Bitwarden bws operation failed'; +} + +function secretRecord(payload: unknown[]): Record { + const result = Object.create(null) as Record; + const seen = new Set(); + + for (const entry of payload) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new BwsAdapterError('Bitwarden bws returned an invalid secret list'); + } + const { key, value } = entry as Record; + if (typeof key !== 'string' || typeof value !== 'string' || value.includes('\0')) { + throw new BwsAdapterError('Bitwarden bws returned an invalid secret list'); + } + if (!ENV_NAME_RE.test(key)) { + throw new BwsAdapterError('Bitwarden secret key is not a valid environment variable name'); + } + if (DANGEROUS_KEYS.has(key)) { + throw new BwsAdapterError('Bitwarden secret key is not allowed'); + } + if (seen.has(key)) { + throw new BwsAdapterError('Bitwarden bws returned duplicate secret keys'); + } + seen.add(key); + result[key] = value; + } + + return result; +} + +export const bitwardenProvider: SecretProvider = { + type: 'bitwarden', + label: 'Bitwarden Secrets Manager', + supportsReferences: false, + supportsBulk: true, + + isReference(_value: unknown): _value is string { + return false; + }, + + async testConnection(config: BitwardenConfig): Promise { + try { + const token = accessToken(config); + const server = serverUrl(config); + await assertSupportedVersion(); + await runJsonArray(['project', 'list', '--output', 'json', '--color', 'no'], { + timeoutMs: COMMAND_TIMEOUT_MS, + stdoutBytes: TEST_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT, + accessToken: token, + serverUrl: server + }); + return { ok: true }; + } catch (error: unknown) { + return { ok: false, error: sanitizedError(error) }; + } + }, + + async resolveSecretReferences(): Promise> { + throw new UnsupportedOperationError( + 'Bitwarden Secrets Manager does not support inline references; use a Project bulk selector.' + ); + }, + + async resolveBulk(config: BitwardenConfig, selector: string): Promise> { + try { + const token = accessToken(config); + const project = projectId(selector); + const server = serverUrl(config); + const payload = await runJsonArray( + ['secret', 'list', project, '--output', 'json', '--color', 'no'], + { + timeoutMs: COMMAND_TIMEOUT_MS, + stdoutBytes: SECRET_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT, + accessToken: token, + serverUrl: server + } + ); + return secretRecord(payload); + } catch (error: unknown) { + if (error instanceof BwsAdapterError) throw error; + throw new BwsAdapterError(sanitizedError(error)); + } + } +}; diff --git a/src/lib/server/secretproviders/index.ts b/src/lib/server/secretproviders/index.ts index 8fb402b..b8ce170 100644 --- a/src/lib/server/secretproviders/index.ts +++ b/src/lib/server/secretproviders/index.ts @@ -18,6 +18,7 @@ import { connectProvider } from './connect'; import { infisicalProvider } from './infisical'; import { vaultProvider } from './vault'; import { dopplerProvider } from './doppler'; +import { bitwardenProvider } from './bitwarden'; // Registered providers. Adding a new backend means dropping a file in this // directory and registering it here; each implements the SecretProvider @@ -27,7 +28,8 @@ const providers: Record = { [connectProvider.type]: connectProvider as SecretProvider, [infisicalProvider.type]: infisicalProvider as SecretProvider, [vaultProvider.type]: vaultProvider as SecretProvider, - [dopplerProvider.type]: dopplerProvider as SecretProvider + [dopplerProvider.type]: dopplerProvider as SecretProvider, + [bitwardenProvider.type]: bitwardenProvider as SecretProvider }; /** Returns the provider for a stored type, or undefined if unknown. */ diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts index ec05eb3..ca48e11 100644 --- a/src/lib/server/secretproviders/shared.ts +++ b/src/lib/server/secretproviders/shared.ts @@ -31,6 +31,7 @@ export type SecretProviderType = | 'infisical' | 'vault' | 'doppler' + | 'bitwarden' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | (string & {}); @@ -169,13 +170,21 @@ export interface DopplerConfig { config?: string; } +/** Bitwarden Secrets Manager: a Machine Account access token. */ +export interface BitwardenConfig { + token: string; + /** Optional Bitwarden server base URL for EU or self-hosted instances. */ + serverUrl?: string; +} + /** Persisted (encrypted) config, discriminated by the provider `type`. */ export type SecretProviderConfig = | ServiceAccountConfig | ConnectConfig | InfisicalConfig | VaultConfig - | DopplerConfig; + | DopplerConfig + | BitwardenConfig; /** * Config keys that hold a SECRET across every provider type. Only these are stripped diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte index f504e12..74d5d8a 100644 --- a/src/routes/settings/secrets/ProviderModal.svelte +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -24,6 +24,7 @@ { value: 'infisical', label: 'Infisical' }, { value: 'vault', label: 'HashiCorp Vault' }, { value: 'doppler', label: 'Doppler' }, + { value: 'bitwarden', label: 'Bitwarden Secrets Manager' }, ]; // Config fields per provider type, matching the config shapes in @@ -54,6 +55,10 @@ { key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)', hint: 'Doppler project slug. Only needed with a personal token.' }, { key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd', hint: 'Config within the project. Only needed with a personal token.' }, ], + bitwarden: [ + { key: 'token', label: 'Machine Account access token', type: 'password', required: true, placeholder: 'Machine Account access token', hint: 'A Bitwarden Secrets Manager Machine Account token with read access to the Project.' }, + { key: 'serverUrl', label: 'Server URL', type: 'text', required: false, placeholder: 'https://vault.bitwarden.com', hint: 'Optional for EU or self-hosted Bitwarden. Leave blank for Bitwarden US cloud.' }, + ], }; export function providerTypeLabel(type: string): string { @@ -80,6 +85,11 @@ label: 'Secret path', placeholder: '/', hint: 'Bulk-load every secret at this path (project and environment come from the provider config).' + }, + 'bitwarden': { + label: 'Project', + placeholder: 'Bitwarden Project UUID', + hint: 'Bulk-load every secret from this Bitwarden Secrets Manager Project.' } }; @@ -398,6 +408,13 @@ {/each} + {#if formType === 'bitwarden'} +

+ Bitwarden Secrets Manager requires an externally installed or mounted official + bws client at /usr/local/bin/bws (or an absolute + DOCKHAND_BWS_PATH process override). +

+ {/if}

Configuration is stored encrypted.{#if isEditing} Leave secret fields blank to keep the existing values.{/if} diff --git a/tests/secret-provider-bitwarden.test.ts b/tests/secret-provider-bitwarden.test.ts new file mode 100644 index 0000000..d5a827b --- /dev/null +++ b/tests/secret-provider-bitwarden.test.ts @@ -0,0 +1,285 @@ +/** Focused tests for the external-bws Bitwarden Secrets Manager provider. */ + +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const { bitwardenProvider } = await import('../src/lib/server/secretproviders/bitwarden.ts'); +const { getProvider } = await import('../src/lib/server/secretproviders/index.ts'); +const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts'); + +const PROJECT_ID = '123e4567-e89b-42d3-a456-426614174000'; +const TOKEN = 'test-machine-account-token'; +const originalBwsPath = process.env.DOCKHAND_BWS_PATH; +let testDir = ''; +let fakeCounter = 0; + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'dockhand-bitwarden-test-')); + delete process.env.DOCKHAND_BWS_PATH; + process.env.UNRELATED_DOCKHAND_SECRET = 'must-not-reach-child'; +}); + +afterEach(async () => { + if (originalBwsPath === undefined) delete process.env.DOCKHAND_BWS_PATH; + else process.env.DOCKHAND_BWS_PATH = originalBwsPath; + delete process.env.UNRELATED_DOCKHAND_SECRET; + await rm(testDir, { recursive: true, force: true }); +}); + +async function installFakeBws(source: string): Promise { + const path = join(testDir, `bws-${fakeCounter++}`); + await writeFile(path, `#!${process.execPath}\n${source}\n`, { mode: 0o700 }); + await chmod(path, 0o700); + process.env.DOCKHAND_BWS_PATH = path; + return path; +} + +async function installSecretResponse(payloadSource: string): Promise { + await installFakeBws(` +const args = process.argv.slice(2); +if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(41); +if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(42); +if (args.includes('${TOKEN}') || process.env.UNRELATED_DOCKHAND_SECRET) process.exit(43); +process.stdout.write(${payloadSource}); +`); +} + +describe('bitwardenProvider capabilities', () => { + test('is registered as a bulk-only provider', () => { + expect(getProvider('bitwarden')).toBe(bitwardenProvider); + expect(bitwardenProvider.type).toBe('bitwarden'); + expect(bitwardenProvider.label).toBe('Bitwarden Secrets Manager'); + expect(bitwardenProvider.supportsBulk).toBe(true); + expect(bitwardenProvider.supportsReferences).toBe(false); + expect(bitwardenProvider.isReference('bw://anything')).toBe(false); + }); + + test('rejects inline reference resolution', async () => { + await expect(bitwardenProvider.resolveSecretReferences({ token: TOKEN }, [])).rejects.toThrow( + UnsupportedOperationError + ); + }); +}); + +describe('testConnection', () => { + test('checks the compatible version, then performs an authenticated project query', async () => { + const logPath = join(testDir, 'calls.jsonl'); + await installFakeBws(` +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ + args, + hasToken: Boolean(process.env.BWS_ACCESS_TOKEN), + home: process.env.HOME, + sameHome: process.env.HOME === process.env.XDG_CONFIG_HOME, + leaked: Boolean(process.env.UNRELATED_DOCKHAND_SECRET) +}) + '\\n'); +if (args.length === 1 && args[0] === '--version') { + if (process.env.BWS_ACCESS_TOKEN) process.exit(51); + console.log('bws 2.1.0'); +} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) { + if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(52); + console.log('[]'); +} else { + process.exit(53); +} +`); + + expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ ok: true }); + const calls = (await readFile(logPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(calls.map((call) => call.args)).toEqual([ + ['--version'], + ['project', 'list', '--output', 'json', '--color', 'no'] + ]); + expect(calls[0].hasToken).toBe(false); + expect(calls[1].hasToken).toBe(true); + expect(calls.every((call) => call.sameHome && !call.leaked)).toBe(true); + expect(calls.every((call) => !existsSync(call.home))).toBe(true); + }); + + test('configures a safe self-hosted server in the isolated profile before authentication', async () => { + const logPath = join(testDir, 'self-hosted-calls.jsonl'); + await installFakeBws(` +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ + args, + hasToken: Boolean(process.env.BWS_ACCESS_TOKEN), + home: process.env.HOME +}) + '\\n'); +if (args.length === 1 && args[0] === '--version') { + console.log('bws 2.1.0'); +} else if (JSON.stringify(args) === JSON.stringify(['config', 'server-base', 'https://bitwarden.example.com'])) { + if (process.env.BWS_ACCESS_TOKEN) process.exit(54); +} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) { + if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(55); + console.log('[]'); +} else { + process.exit(56); +} +`); + + expect( + await bitwardenProvider.testConnection({ + token: TOKEN, + serverUrl: ' https://bitwarden.example.com/ ' + }) + ).toEqual({ ok: true }); + const calls = (await readFile(logPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(calls.map((call) => call.args)).toEqual([ + ['--version'], + ['config', 'server-base', 'https://bitwarden.example.com'], + ['project', 'list', '--output', 'json', '--color', 'no'] + ]); + expect(calls.map((call) => call.hasToken)).toEqual([false, false, true]); + expect(calls[1].home).toBe(calls[2].home); + expect(calls.every((call) => !existsSync(call.home))).toBe(true); + }); + + test('rejects an unsafe self-hosted server before executing bws', async () => { + process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); + expect( + await bitwardenProvider.testConnection({ token: TOKEN, serverUrl: 'http://127.0.0.1' }) + ).toEqual({ + ok: false, + error: + 'Bitwarden Secrets Manager: host not allowed (loopback address (127.0.0.1) blocked)' + }); + }); + + test('rejects missing, relative-path, and incompatible clients with sanitized errors', async () => { + process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); + expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ + ok: false, + error: 'Bitwarden bws executable was not found' + }); + + process.env.DOCKHAND_BWS_PATH = 'relative/bws'; + expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ + ok: false, + error: 'Bitwarden bws executable path must be absolute' + }); + + for (const version of ['2.0.9', '2.1.0-beta.1', '3.0.0']) { + await installFakeBws(`console.log('bws ${version}');`); + expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ + ok: false, + error: 'Bitwarden bws version must be >=2.1.0 and <3.0.0' + }); + } + }); + + test('rejects an empty token before executing bws', async () => { + process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); + expect(await bitwardenProvider.testConnection({ token: ' ' })).toEqual({ + ok: false, + error: 'Bitwarden Machine Account access token is empty' + }); + }); +}); + +describe('resolveBulk', () => { + test('uses the exact command and returns a null-prototype string record', async () => { + const statePath = join(testDir, 'state-home'); + await installFakeBws(` +const fs = require('node:fs'); +const args = process.argv.slice(2); +if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(61); +if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(62); +if (process.env.UNRELATED_DOCKHAND_SECRET || process.env.HOME !== process.env.XDG_CONFIG_HOME) process.exit(63); +fs.writeFileSync(${JSON.stringify(statePath)}, process.env.HOME); +console.log(JSON.stringify([{ key: 'DB_PASSWORD', value: 'hunter2' }, { key: '_PORT', value: '5432' }])); +`); + + const result = await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID); + expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.entries(result)).toEqual([ + ['DB_PASSWORD', 'hunter2'], + ['_PORT', '5432'] + ]); + const stateHome = await readFile(statePath, 'utf8'); + expect(existsSync(stateHome)).toBe(false); + }); + + test('validates the Project UUID before execution', async () => { + process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); + await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, 'not-a-uuid')).rejects.toThrow( + 'Bitwarden Project selector must be a valid UUID' + ); + }); + + test('rejects duplicate, invalid, dangerous, and malformed secret fields', async () => { + const cases: Array<[unknown, string]> = [ + [ + [ + { key: 'DUPLICATE', value: 'one' }, + { key: 'DUPLICATE', value: 'two' } + ], + 'duplicate secret keys' + ], + [[{ key: 'BAD-NAME', value: 'value' }], 'not a valid environment variable name'], + [[{ key: 'constructor', value: 'value' }], 'secret key is not allowed'], + [[{ key: 'PORT', value: 5432 }], 'invalid secret list'], + [[{ key: 'VALUE', value: 'contains\0nul' }], 'invalid secret list'], + [['not-an-object'], 'invalid secret list'] + ]; + + for (const [payload, expected] of cases) { + await installSecretResponse(JSON.stringify(JSON.stringify(payload))); + await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( + expected + ); + } + }); + + test('rejects malformed JSON and a non-array root', async () => { + await installSecretResponse(JSON.stringify('{broken')); + await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( + 'Bitwarden bws returned invalid JSON' + ); + + await installSecretResponse(JSON.stringify('{"key":"value"}')); + await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( + 'Bitwarden bws returned an invalid JSON response' + ); + }); + + test('does not expose stderr, tokens, or secret output on process failure', async () => { + await installFakeBws(` +process.stdout.write('secret-output'); +process.stderr.write('raw stderr ${TOKEN} another-secret'); +process.exit(7); +`); + try { + await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID); + throw new Error('expected resolveBulk to fail'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toBe('Bitwarden bws command failed'); + expect(message).not.toContain(TOKEN); + expect(message).not.toContain('secret-output'); + expect(message).not.toContain('raw stderr'); + } + }); + + test('bounds stdout and terminates an oversized command', async () => { + await installFakeBws(` +process.on('SIGTERM', () => process.exit(0)); +process.stdout.write('x'.repeat(11 * 1024 * 1024)); +setTimeout(() => {}, 60_000); +`); + await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( + 'Bitwarden bws command exceeded the stdout limit' + ); + }); +});