Add Bitwarden Secrets Manager provider via external bws (#1416)

* add external bws Bitwarden secrets provider

* add self-hosted Bitwarden server support
This commit is contained in:
StefanSa authored and GitHub committed 2026-08-17 08:15:36 +02:00
1 parent 3b43946a38
commit 593659f652
6 files changed
+708 -2

No files matched your search

+23
View File
@@ -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
<table>
+370
View File
@@ -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<Buffer> {
const stdoutChunks: Buffer[] = [];
try {
return await new Promise<Buffer>((resolve, reject) => {
let stdoutBytes = 0;
let stderrBytes = 0;
let failure: BwsAdapterError | undefined;
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | 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<Buffer> {
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<unknown[]> {
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<void> {
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<string, string> {
const result = Object.create(null) as Record<string, string>;
const seen = new Set<string>();
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<string, unknown>;
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<BitwardenConfig> = {
type: 'bitwarden',
label: 'Bitwarden Secrets Manager',
supportsReferences: false,
supportsBulk: true,
isReference(_value: unknown): _value is string {
return false;
},
async testConnection(config: BitwardenConfig): Promise<TestConnectionResult> {
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<Map<string, string>> {
throw new UnsupportedOperationError(
'Bitwarden Secrets Manager does not support inline references; use a Project bulk selector.'
);
},
async resolveBulk(config: BitwardenConfig, selector: string): Promise<Record<string, string>> {
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));
}
}
};
+3 -1
View File
@@ -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<string, SecretProvider> = {
[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. */
+10 -1
View File
@@ -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
@@ -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.'
}
};
</script>
@@ -398,6 +408,13 @@
</div>
{/each}
</div>
{#if formType === 'bitwarden'}
<p class="text-xs text-muted-foreground">
Bitwarden Secrets Manager requires an externally installed or mounted official
<code>bws</code> client at <code>/usr/local/bin/bws</code> (or an absolute
<code>DOCKHAND_BWS_PATH</code> process override).
</p>
{/if}
<p class="text-xs text-muted-foreground">
Configuration is stored encrypted.{#if isEditing}
Leave secret fields blank to keep the existing values.{/if}
+285
View File
@@ -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<string> {
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<void> {
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'
);
});
});