From 52d99fa295bca7d04b279408bc390ab984355036 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Wed, 2 Sep 2026 15:52:45 +0200 Subject: [PATCH] escape dollar signs in env values when generating a compose file from a container --- src/lib/utils/inspect-to-compose.ts | 17 ++++++++++- tests/inspect-to-compose.test.ts | 47 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/inspect-to-compose.ts b/src/lib/utils/inspect-to-compose.ts index 39e3708..6e3719d 100644 --- a/src/lib/utils/inspect-to-compose.ts +++ b/src/lib/utils/inspect-to-compose.ts @@ -184,6 +184,21 @@ function portSpec(containerPort: string, bindings: Array<{ HostIp?: string; Host }); } +/** + * Escape `$` -> `$$` in the VALUE half of a `KEY=VALUE` env entry so `docker compose` + * does not interpolate it (#1507). Inspect values are already resolved literals (the + * daemon expanded any real variable), so every `$` must survive verbatim - e.g. a + * bcrypt hash `$2a$12$...` would otherwise be read as variable references and mangled. + * Only the value is escaped; the key never contains `$`. + */ +function escapeEnvEntryForCompose(entry: string): string { + const eq = entry.indexOf('='); + if (eq < 0) return entry; + const key = entry.slice(0, eq); + const value = entry.slice(eq + 1); + return `${key}=${value.replace(/\$/g, '$$$$')}`; +} + /** Convert a nanosecond duration (Docker healthcheck) to a compose `30s` string. */ function nsToDuration(ns: number | undefined): string | undefined { if (!ns || ns <= 0) return undefined; @@ -225,7 +240,7 @@ export function inspectToComposeService( // Environment: drop image-baked vars when the image env is provided. if (Array.isArray(config.Env) && config.Env.length > 0) { const imageEnv = new Set(options.imageEnv ?? []); - const env = config.Env.filter((e) => !imageEnv.has(e)); + const env = config.Env.filter((e) => !imageEnv.has(e)).map(escapeEnvEntryForCompose); if (env.length > 0) service.environment = env; } diff --git a/tests/inspect-to-compose.test.ts b/tests/inspect-to-compose.test.ts index fee0e9f..b62859a 100644 --- a/tests/inspect-to-compose.test.ts +++ b/tests/inspect-to-compose.test.ts @@ -249,6 +249,53 @@ describe('inspectToCompose - default-noise filtering', () => { }); }); +describe('inspectToCompose - env value $ escaping (#1507)', () => { + // docker compose interpolates $ in env values; an inspect value is already a resolved + // literal, so every $ must be escaped to $$ or compose eats it (a bcrypt hash + // $2a$12$... becomes $2a$12., silently corrupting the password). + test('a bcrypt hash keeps every $ as $$ so compose interpolation leaves it intact', () => { + const inspect: DockerInspect = { + Name: '/wg-easy', + Config: { + Image: 'ghcr.io/wg-easy/wg-easy:latest', + Env: ['PASSWORD_HASH=$2a$12$r68XHj7T7304UoWeYJc1BOSwjmyeBj.gzk7PnLOKv5AED8C5NNj5C'] + }, + HostConfig: {}, + NetworkSettings: { Networks: {} } + }; + const svc = parseCompose(inspect).doc.services['wg-easy']; + const entry = svc.environment.find((e: string) => e.startsWith('PASSWORD_HASH=')); + expect(entry).toBe('PASSWORD_HASH=$$2a$$12$$r68XHj7T7304UoWeYJc1BOSwjmyeBj.gzk7PnLOKv5AED8C5NNj5C'); + // After compose collapses $$ -> $, the original hash is recovered verbatim. + const value = entry.slice('PASSWORD_HASH='.length).replaceAll('$$', '$'); + expect(value).toBe('$2a$12$r68XHj7T7304UoWeYJc1BOSwjmyeBj.gzk7PnLOKv5AED8C5NNj5C'); + }); + + test('a value with no $ is left untouched', () => { + const inspect: DockerInspect = { + Name: '/app', + Config: { Image: 'x', Env: ['WG_HOST=example.me', 'PORT=51820'] }, + HostConfig: {}, + NetworkSettings: { Networks: {} } + }; + const svc = parseCompose(inspect).doc.services['app']; + expect(svc.environment).toContain('WG_HOST=example.me'); + expect(svc.environment).toContain('PORT=51820'); + }); + + test('a literal ${VAR}-looking value is escaped so compose does not expand it', () => { + const inspect: DockerInspect = { + Name: '/app', + Config: { Image: 'x', Env: ['TEMPLATE=${NOT_A_VAR}-literal'] }, + HostConfig: {}, + NetworkSettings: { Networks: {} } + }; + const svc = parseCompose(inspect).doc.services['app']; + const entry = svc.environment.find((e: string) => e.startsWith('TEMPLATE=')); + expect(entry).toBe('TEMPLATE=$${NOT_A_VAR}-literal'); + }); +}); + describe('inspectToCompose - full network model (static IP, aliases, MAC)', () => { test('static IPv4/IPv6, user aliases and MAC render as a per-network map', () => { const inspect: DockerInspect = {