escape dollar signs in env values when generating a compose file from a container

This commit is contained in:
Jarek Krochmalski committed 2026-09-02 15:52:45 +02:00
1 parent d03554c69d
commit 52d99fa295
2 files changed
+63 -1

No files matched your search

+16 -1
View File
@@ -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;
}
+47
View File
@@ -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.<tail>, 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 = {