= {
[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'
+ );
+ });
+});