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:
1 parent
3b43946a38
commit
593659f652
6 files changed
+708
-2
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user