fix(compose-doctor): recognize Docker socket proxy topologies (#1791)

* fix(compose-doctor): recognize Docker socket proxy topologies

Classify dedicated socket proxies separately from direct docker.sock mounts so Doctor no longer recommends adopting a proxy the stack already uses. Closes #1790.

* fix(compose-doctor): widen socket proxy detection and flag writable proxy sockets

Close the remaining gaps in socket proxy topology handling: a service that
points at a proxy through a tcp:// endpoint on its command line (how Traefik
and friends do it) now gets the client note, proxy API group flags are read
for any truthy value rather than a literal 1, and underscore or dot separated
proxy names are recognized.

Two cases that previously slipped through now surface: a service classified
as a proxy purely by name or image but mounting docker.sock read-write is
reported as high, and a proxy on the implicit default network or on a network
the rendered model does not describe counts as non-internal. A direct socket
mount alongside an existing proxy now names that proxy in its fix.

* fix(compose-doctor): require corroboration before a service name classifies a socket proxy

A service name is free text the author controls, so on its own it could move a
writable docker.sock mount out of the high direct-mount finding. A known proxy
image is an artifact identity and still stands alone; a proxy-shaped name now
counts only alongside an observable fact, a read-only socket or a scoped API
group key.

* fix(compose-doctor): tighten socket-proxy detection against live upstream behavior

Require proxy API flags to be exactly 1 (matching tecnativa and linuxserver
images), count only those enabled flags when classifying a proxy, extract
tcp hosts from DOCKER_HOST instead of treating key presence as a proxy client,
and correlate each client note to one proxy instance by both name and shared
network. Soften the published-port finding so it claims reachability rather
than Docker API exposure for unrelated ports.
This commit is contained in:
Anso
2026-08-08 01:32:26 -04:00
committed by GitHub
parent 5c52ae26eb
commit bb98cba1f2
13 changed files with 996 additions and 29 deletions
@@ -207,6 +207,94 @@ describe('runPreflight', () => {
expect(report.status).toBe('pass');
});
it('classifies a safe socket proxy as info instead of a high socket mount', async () => {
stubDocker({
name: STACK,
services: {
proxy: {
image: 'lscr.io/linuxserver/socket-proxy:v1',
restart: 'unless-stopped',
healthcheck: { test: ['CMD', 'true'] },
volumes: [{
type: 'bind',
source: '/var/run/docker.sock',
target: '/var/run/docker.sock',
read_only: true,
}],
environment: { CONTAINERS: '1', IMAGES: '1' },
networks: { app_internal: null },
},
},
networks: { app_internal: { name: `${STACK}_app_internal`, internal: true } },
volumes: {},
});
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.findings.some(f => f.ruleId === 'docker-socket-mount')).toBe(false);
expect(report.findings.some(f => f.ruleId === 'docker-socket-proxy')).toBe(true);
expect(report.activeStatus).toBe('info');
expect(report.activeHighestSeverity).toBe('info');
});
it('keeps a direct application socket mount as high', async () => {
stubDocker({
name: STACK,
services: {
app: {
image: 'portainer/portainer-ce:2.19.0',
restart: 'unless-stopped',
healthcheck: { test: ['CMD', 'true'] },
volumes: [{
type: 'bind',
source: '/var/run/docker.sock',
target: '/var/run/docker.sock',
}],
},
},
networks: {},
volumes: {},
});
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.findings.some(f => f.ruleId === 'docker-socket-mount')).toBe(true);
expect(report.activeStatus).toBe('high');
});
it('excludes the socket-proxy client note from active severity while keeping proxy info', async () => {
stubDocker({
name: STACK,
services: {
proxy: {
image: 'tecnativa/docker-socket-proxy:v0.1',
restart: 'unless-stopped',
healthcheck: { test: ['CMD', 'true'] },
volumes: [{
type: 'bind',
source: '/var/run/docker.sock',
target: '/var/run/docker.sock',
read_only: true,
}],
environment: { CONTAINERS: '1' },
networks: { app_internal: null },
},
app: {
image: 'myapp:1.0',
restart: 'unless-stopped',
healthcheck: { test: ['CMD', 'true'] },
environment: { DOCKER_HOST: 'tcp://proxy:2375' },
networks: { app_internal: null },
},
},
networks: { app_internal: { name: `${STACK}_app_internal`, internal: true } },
volumes: {},
});
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.findings.some(f => f.ruleId === 'docker-socket-proxy-client')).toBe(true);
expect(report.findings.some(f => f.ruleId === 'docker-socket-proxy')).toBe(true);
expect(report.activeStatus).toBe('info');
const issueCount = report.findings.filter(f => f.ruleId !== 'docker-socket-proxy-client').length;
expect(report.activeCount).toBe(issueCount);
expect(JSON.stringify(report)).not.toContain('tcp://proxy:2375');
});
it('returns an unrenderable report and never stores raw stderr', async () => {
stubDocker(null, `bad yaml near ${SECRET}`); // stderr can echo arbitrary file content
const report = await doctor().runPreflight(nodeId, STACK, null);
@@ -26,6 +26,8 @@ function effSvc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, restart: 'unless-stopped', envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
@@ -49,6 +49,8 @@ function effSvc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, restart: 'unless-stopped', envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
+2
View File
@@ -16,6 +16,8 @@ function svc(overrides: Record<string, unknown>) {
hasHealthcheck: false,
composeHealthcheck: 'absent' as const,
envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
networks: [],
extraHosts: [],
labelKeys: [],
@@ -14,6 +14,8 @@ function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, restart: 'unless-stopped', envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
@@ -146,6 +146,94 @@ describe('parseEffectiveModel', () => {
expect(JSON.stringify(m)).not.toContain(SECRET);
});
it('records enabled socket-proxy API flags when the value is exactly 1', () => {
const mapForm = parseEffectiveModel({
services: {
proxy: {
environment: {
CONTAINERS: '1',
POST: '1',
DELETE: '0',
IMAGES: '1',
DB_PASSWORD: SECRET,
},
},
},
}, 'p');
expect(mapForm.services[0].enabledProxyApiFlags.sort()).toEqual(['CONTAINERS', 'IMAGES', 'POST']);
expect(mapForm.services[0].envKeys).toContain('DB_PASSWORD');
expect(JSON.stringify(mapForm)).not.toContain(SECRET);
const arrayForm = parseEffectiveModel({
services: {
proxy: {
environment: ['POST=1', 'DELETE=0', 'EVENTS=1', `TOKEN=${SECRET}`],
},
},
}, 'p');
expect(arrayForm.services[0].enabledProxyApiFlags.sort()).toEqual(['EVENTS', 'POST']);
expect(JSON.stringify(arrayForm)).not.toContain(SECRET);
const absent = parseEffectiveModel({
services: { proxy: { environment: { CONTAINERS: '1' } } },
}, 'p');
expect(absent.services[0].enabledProxyApiFlags).toEqual(['CONTAINERS']);
expect(absent.services[0].enabledProxyApiFlags).not.toContain('POST');
// Only the literal value `1` enables a group. Upstream proxy images 403
// every other value (true/yes/banana/on), so truthy-word semantics would
// false-positive a locked-down config as mutating.
const nonExact = parseEffectiveModel({
services: {
proxy: {
environment: {
POST: 'true', DELETE: ' YES ', IMAGES: 'false', INFO: 'on',
EVENTS: 'off', NETWORKS: '', VOLUMES: 'enabled', CONTAINERS: '1',
},
},
},
}, 'p');
expect(nonExact.services[0].enabledProxyApiFlags).toEqual(['CONTAINERS']);
});
it('keeps only the host of a tcp:// Docker endpoint from command, entrypoint, and DOCKER_HOST', () => {
const m = parseEffectiveModel({
services: {
traefik: {
command: ['--providers.docker.endpoint=tcp://dockerproxy:2375', `--certificatesresolvers.le.acme.email=${SECRET}`],
entrypoint: '/entrypoint.sh --host tcp://other-proxy',
},
plain: { command: 'serve' },
},
}, 'p');
expect(m.services[0].dockerEndpointHosts.sort()).toEqual(['dockerproxy', 'other-proxy']);
expect(m.services[1].dockerEndpointHosts).toEqual([]);
expect(JSON.stringify(m)).not.toContain(SECRET);
const withUserInfo = parseEffectiveModel({
services: { app: { command: [`-H tcp://admin:${SECRET}@dockerproxy:2375`] } },
}, 'p');
expect(withUserInfo.services[0].dockerEndpointHosts).toEqual(['dockerproxy']);
expect(JSON.stringify(withUserInfo)).not.toContain(SECRET);
expect(JSON.stringify(withUserInfo)).not.toContain('admin');
// DOCKER_HOST contributes its tcp host; unix:// and empty values do not.
const fromEnv = parseEffectiveModel({
services: {
app: { environment: { DOCKER_HOST: `tcp://admin:${SECRET}@proxy:2375` } },
unix: { environment: { DOCKER_HOST: 'unix:///var/run/docker.sock' } },
empty: { environment: { DOCKER_HOST: '' } },
arrayForm: { environment: [`DOCKER_HOST=tcp://socket-proxy:2375`] },
},
}, 'p');
expect(fromEnv.services.find(s => s.name === 'app')!.dockerEndpointHosts).toEqual(['proxy']);
expect(fromEnv.services.find(s => s.name === 'unix')!.dockerEndpointHosts).toEqual([]);
expect(fromEnv.services.find(s => s.name === 'empty')!.dockerEndpointHosts).toEqual([]);
expect(fromEnv.services.find(s => s.name === 'arrayForm')!.dockerEndpointHosts).toEqual(['socket-proxy']);
expect(JSON.stringify(fromEnv)).not.toContain(SECRET);
expect(JSON.stringify(fromEnv)).not.toContain('admin');
});
it('parses the short-string port form and drops container-only EXPOSE', () => {
const m = parseEffectiveModel({ services: { s: { ports: ['127.0.0.1:8080:80/udp', '8443:443', '90'] } } }, 'p');
expect(m.services[0].ports).toEqual([
+429 -4
View File
@@ -16,6 +16,8 @@ function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, restart: 'unless-stopped', envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
@@ -190,10 +192,429 @@ describe('bind-path-missing / bind-path-permission', () => {
});
describe('security rules', () => {
it('flags a docker socket mount', () => {
const m = model([svc({ binds: [{ source: '/var/run/docker.sock', target: '/var/run/docker.sock' }] })]);
expect(ids(runRules(ctx({ model: m })), 'docker-socket-mount')[0].severity).toBe('high');
const sockBind = { source: '/var/run/docker.sock', target: '/var/run/docker.sock' };
const sockRo = { type: 'bind' as const, source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: true };
const sockRw = { type: 'bind' as const, source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false };
const internalNet = { app_internal: { name: 'proj_app_internal', external: false, internal: true } };
const publicNet = { lan: { name: 'proj_lan', external: false, internal: false } };
it('flags a direct docker socket mount as high', () => {
const m = model([svc({ binds: [sockBind], storageMounts: [sockRw] })]);
const f = ids(runRules(ctx({ model: m })), 'docker-socket-mount');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].remediation).toMatch(/scoped socket proxy/i);
expect(ids(runRules(ctx({ model: m })), 'docker-socket-proxy')).toHaveLength(0);
});
it('classifies known proxy images as info, not high', () => {
const m = model([svc({
name: 'proxy',
image: 'lscr.io/linuxserver/socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
})], { networks: internalNet });
expect(ids(runRules(ctx({ model: m })), 'docker-socket-mount')).toHaveLength(0);
const f = ids(runRules(ctx({ model: m })), 'docker-socket-proxy');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('info');
});
it('classifies tecnativa image and corroborated name-hint proxies', () => {
const byImage = model([svc({
name: 'api', image: 'tecnativa/docker-socket-proxy:latest', binds: [sockBind], storageMounts: [sockRw],
})]);
expect(ids(runRules(ctx({ model: byImage })), 'docker-socket-proxy')).toHaveLength(1);
const byNameAndReadOnly = model([svc({
name: 'docker-socket-proxy', image: 'custom/proxy:1', binds: [sockBind], storageMounts: [sockRo],
})]);
expect(ids(runRules(ctx({ model: byNameAndReadOnly })), 'docker-socket-proxy')).toHaveLength(1);
const byNameAndApiKey = model([svc({
name: 'docker-socket-proxy', image: 'custom/proxy:1', binds: [sockBind], storageMounts: [sockRw],
enabledProxyApiFlags: ['CONTAINERS'],
})]);
expect(ids(runRules(ctx({ model: byNameAndApiKey })), 'docker-socket-proxy')).toHaveLength(1);
});
it('does not let a service name alone downgrade a writable socket mount', () => {
// The name is free text the author controls; without a read-only socket or
// an enabled API group flag there is nothing observable to corroborate it.
const nameOnly = model([svc({
name: 'docker-socket-proxy', image: 'custom/proxy:1', binds: [sockBind], storageMounts: [sockRw],
})]);
expect(ids(runRules(ctx({ model: nameOnly })), 'docker-socket-proxy')).toHaveLength(0);
expect(ids(runRules(ctx({ model: nameOnly })), 'docker-socket-mount')[0].severity).toBe('high');
});
it('classifies unknown RO socket plus two enabled API flags as proxy; RW stays high', () => {
const keys = ['CONTAINERS', 'IMAGES'];
const ro = model([svc({
name: 'mystery', image: 'custom:1', binds: [sockBind], storageMounts: [sockRo],
enabledProxyApiFlags: keys, envKeys: keys,
})]);
expect(ids(runRules(ctx({ model: ro })), 'docker-socket-proxy')).toHaveLength(1);
expect(ids(runRules(ctx({ model: ro })), 'docker-socket-mount')).toHaveLength(0);
const rw = model([svc({
name: 'mystery', image: 'custom:1', binds: [sockBind], storageMounts: [sockRw],
enabledProxyApiFlags: keys, envKeys: keys,
})]);
expect(ids(runRules(ctx({ model: rw })), 'docker-socket-mount')[0].severity).toBe('high');
expect(ids(runRules(ctx({ model: rw })), 'docker-socket-proxy')).toHaveLength(0);
});
it('does not classify from disabled API flag keys alone', () => {
// Key presence with value 0 is not an enabled group; the direct-mount High stays.
const disabled = model([svc({
name: 'mystery', image: 'custom:1', binds: [sockBind], storageMounts: [sockRo],
envKeys: ['CONTAINERS', 'IMAGES'], enabledProxyApiFlags: [],
})]);
expect(ids(runRules(ctx({ model: disabled })), 'docker-socket-proxy')).toHaveLength(0);
expect(ids(runRules(ctx({ model: disabled })), 'docker-socket-mount')[0].severity).toBe('high');
});
it('does not downgrade on a single API key or Portainer', () => {
const oneKey = model([svc({
binds: [sockBind], storageMounts: [sockRo], enabledProxyApiFlags: ['CONTAINERS'],
})]);
expect(ids(runRules(ctx({ model: oneKey })), 'docker-socket-mount')).toHaveLength(1);
const portainer = model([svc({
name: 'portainer', image: 'portainer/portainer-ce:latest', binds: [sockBind], storageMounts: [sockRw],
})]);
expect(ids(runRules(ctx({ model: portainer })), 'docker-socket-mount')).toHaveLength(1);
});
it('matches docker.sock on source-only or target-only binds', () => {
const sourceOnly = model([svc({
binds: [{ source: '/var/run/docker.sock', target: '/run/docker.sock' }],
storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/run/docker.sock', readOnly: false }],
})]);
expect(ids(runRules(ctx({ model: sourceOnly })), 'docker-socket-mount')).toHaveLength(1);
const targetOnly = model([svc({
binds: [{ source: '/host/custom', target: '/var/run/docker.sock' }],
storageMounts: [{ type: 'bind', source: '/host/custom', target: '/var/run/docker.sock', readOnly: false }],
})]);
expect(ids(runRules(ctx({ model: targetOnly })), 'docker-socket-mount')).toHaveLength(1);
});
it('warns when a proxy publishes any host port', () => {
const m = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
ports: [{ startPort: 2375, endPort: 2375, hostIp: '', protocol: 'tcp' }],
networks: [{ key: 'app_internal', aliases: [] }],
})], { networks: internalNet });
const published = ids(runRules(ctx({ model: m })), 'docker-socket-proxy-published');
expect(published[0].severity).toBe('high');
expect(published[0].message).toMatch(/make the proxy reachable/i);
expect(published[0].message).not.toMatch(/expose Docker API access/i);
expect(published[0].message).toContain('2375');
const remapped = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
ports: [{ startPort: 12375, endPort: 12375, hostIp: '127.0.0.1', protocol: 'tcp' }],
})]);
expect(ids(runRules(ctx({ model: remapped })), 'docker-socket-proxy-published')).toHaveLength(1);
const none = model([svc({
name: 'proxy', image: 'tecnativa/docker-socket-proxy:latest', binds: [sockBind], storageMounts: [sockRo],
})]);
expect(ids(runRules(ctx({ model: none })), 'docker-socket-proxy-published')).toHaveLength(0);
});
it('warns on enabled POST/DELETE flags only', () => {
const mutating = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
enabledProxyApiFlags: ['POST', 'DELETE', 'CONTAINERS'],
})]);
const f = ids(runRules(ctx({ model: mutating })), 'docker-socket-proxy-mutating');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('warning');
expect(f[0].message).toMatch(/POST and DELETE/);
const safe = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
enabledProxyApiFlags: ['CONTAINERS'],
envKeys: ['POST', 'DELETE', 'CONTAINERS'],
})]);
expect(ids(runRules(ctx({ model: safe })), 'docker-socket-proxy-mutating')).toHaveLength(0);
});
it('warns when a proxy attaches to any non-internal network, including mixed', () => {
const internalOnly = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
})], { networks: internalNet });
expect(ids(runRules(ctx({ model: internalOnly })), 'docker-socket-proxy-exposure')).toHaveLength(0);
const mixed = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }, { key: 'lan', aliases: [] }],
})], { networks: { ...internalNet, ...publicNet } });
expect(ids(runRules(ctx({ model: mixed })), 'docker-socket-proxy-exposure')).toHaveLength(1);
// A network the model cannot show to be internal, and the implicit default
// network, both count as non-internal.
const missingMeta = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'undeclared', aliases: [] }],
})]);
expect(ids(runRules(ctx({ model: missingMeta })), 'docker-socket-proxy-exposure')).toHaveLength(1);
const implicitDefault = model([svc({
name: 'proxy', image: 'tecnativa/docker-socket-proxy:latest', binds: [sockBind], storageMounts: [sockRo],
})]);
expect(ids(runRules(ctx({ model: implicitDefault })), 'docker-socket-proxy-exposure')).toHaveLength(1);
});
it('flags a classified proxy that mounts the socket read-write as high', () => {
const rw = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRw],
networks: [{ key: 'app_internal', aliases: [] }],
})], { networks: internalNet });
const f = ids(runRules(ctx({ model: rw })), 'docker-socket-proxy-writable');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
// A name-hint match corroborated by an enabled API group flag cannot silence it either.
const byName = model([svc({
name: 'my-socket-proxy', image: 'custom:1', binds: [sockBind], storageMounts: [sockRw],
enabledProxyApiFlags: ['CONTAINERS'],
})]);
expect(ids(runRules(ctx({ model: byName })), 'docker-socket-proxy-writable')[0].severity).toBe('high');
const ro = model([svc({
name: 'proxy', image: 'tecnativa/docker-socket-proxy:latest', binds: [sockBind], storageMounts: [sockRo],
})]);
expect(ids(runRules(ctx({ model: ro })), 'docker-socket-proxy-writable')).toHaveLength(0);
// A second, writable socket mount is not made safe by the read-only one.
const both = model([svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo, { type: 'bind', source: '/var/run/docker.sock', target: '/tmp/docker.sock', readOnly: false }],
})]);
expect(ids(runRules(ctx({ model: both })), 'docker-socket-proxy-writable')).toHaveLength(1);
});
it('matches underscore and dot separated proxy names', () => {
const underscore = model([svc({
name: 'docker_socket_proxy', image: 'custom/proxy:1', binds: [sockBind], storageMounts: [sockRo],
})]);
expect(ids(runRules(ctx({ model: underscore })), 'docker-socket-proxy')).toHaveLength(1);
expect(ids(runRules(ctx({ model: underscore })), 'docker-socket-mount')).toHaveLength(0);
});
it('points a direct mount at the proxy the stack already runs', () => {
const m = model([
svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
}),
svc({
name: 'app', image: 'myapp:1', binds: [sockBind], storageMounts: [sockRw],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
const f = ids(runRules(ctx({ model: m })), 'docker-socket-mount');
expect(f).toHaveLength(1);
expect(f[0].service).toBe('app');
expect(f[0].remediation).toMatch(/already runs a socket proxy \("proxy"\)/);
});
it('emits a client note for a tcp:// endpoint on the command line', () => {
const m = model([
svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: ['dockerproxy'] }],
}),
svc({
name: 'traefik',
image: 'traefik:v3',
dockerEndpointHosts: ['dockerproxy'],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
const note = ids(runRules(ctx({ model: m })), 'docker-socket-proxy-client');
expect(note).toHaveLength(1);
expect(note[0].service).toBe('traefik');
const unrelatedHost = model([
svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
}),
svc({
name: 'traefik', image: 'traefik:v3', dockerEndpointHosts: ['somewhere-else'],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: unrelatedHost })), 'docker-socket-proxy-client')).toHaveLength(0);
});
it('emits a client note when DOCKER_HOST names a proxy on a shared network', () => {
const m = model([
svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
}),
svc({
name: 'app',
image: 'myapp:1',
envKeys: ['DOCKER_HOST'],
dockerEndpointHosts: ['proxy'],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
const note = ids(runRules(ctx({ model: m })), 'docker-socket-proxy-client');
expect(note).toHaveLength(1);
expect(note[0].message).toMatch(/appears to use/i);
});
it('does not emit a client note for DOCKER_HOST that does not name a reachable proxy', () => {
const proxy = svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
});
// Key presence alone (no extracted tcp host) is not enough.
const keyOnly = model([
proxy,
svc({
name: 'app', envKeys: ['DOCKER_HOST'],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: keyOnly })), 'docker-socket-proxy-client')).toHaveLength(0);
// Host points at a remote daemon, not the in-stack proxy.
const remote = model([
proxy,
svc({
name: 'app', envKeys: ['DOCKER_HOST'], dockerEndpointHosts: ['daemon.example.com'],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: remote })), 'docker-socket-proxy-client')).toHaveLength(0);
});
it('requires the client to share a network with the same proxy it names', () => {
// Multi-proxy cross-correlation: client on proxy-a's network naming
// unreachable proxy-b must not get the note.
const m = model([
svc({
name: 'proxy-a',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'net-a', aliases: [] }],
}),
svc({
name: 'proxy-b',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'net-b', aliases: [] }],
}),
svc({
name: 'app',
image: 'myapp:1',
dockerEndpointHosts: ['proxy-b'],
networks: [{ key: 'net-a', aliases: [] }],
}),
], { networks: { 'net-a': { name: 'net-a', external: false, internal: true }, 'net-b': { name: 'net-b', external: false, internal: true } } });
expect(ids(runRules(ctx({ model: m })), 'docker-socket-proxy-client')).toHaveLength(0);
// Same host and network for one proxy still fires.
const matched = model([
svc({
name: 'proxy-a',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'net-a', aliases: [] }],
}),
svc({
name: 'proxy-b',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'net-b', aliases: [] }],
}),
svc({
name: 'app', image: 'myapp:1', dockerEndpointHosts: ['proxy-a'],
networks: [{ key: 'net-a', aliases: [] }],
}),
], { networks: { 'net-a': { name: 'net-a', external: false, internal: true }, 'net-b': { name: 'net-b', external: false, internal: true } } });
expect(ids(runRules(ctx({ model: matched })), 'docker-socket-proxy-client')).toHaveLength(1);
});
it('skips the client note without a matching host, shared network, or when the app mounts the socket', () => {
const proxy = svc({
name: 'proxy',
image: 'tecnativa/docker-socket-proxy:latest',
binds: [sockBind],
storageMounts: [sockRo],
networks: [{ key: 'app_internal', aliases: [] }],
});
const noKey = model([
proxy,
svc({ name: 'app', networks: [{ key: 'app_internal', aliases: [] }] }),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: noKey })), 'docker-socket-proxy-client')).toHaveLength(0);
const noShare = model([
proxy,
svc({
name: 'app', envKeys: ['DOCKER_HOST'], dockerEndpointHosts: ['proxy'],
networks: [{ key: 'other', aliases: [] }],
}),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: noShare })), 'docker-socket-proxy-client')).toHaveLength(0);
const alsoMounts = model([
proxy,
svc({
name: 'app',
envKeys: ['DOCKER_HOST'],
dockerEndpointHosts: ['proxy'],
binds: [sockBind],
storageMounts: [sockRw],
networks: [{ key: 'app_internal', aliases: [] }],
}),
], { networks: internalNet });
expect(ids(runRules(ctx({ model: alsoMounts })), 'docker-socket-proxy-client')).toHaveLength(0);
});
it('flags privileged and host networking', () => {
expect(ids(runRules(ctx({ model: model([svc({ privileged: true })]) })), 'privileged')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ networkMode: 'host' })]) })), 'network-mode-host')).toHaveLength(1);
@@ -543,7 +964,11 @@ describe('rule registry completeness', () => {
// which forces a deliberate pass over the docs and the frontend severity map.
const EXPECTED_RULE_IDS = [
'render-failed', 'env-unset', 'env-literal-dollar', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount',
'docker-socket-proxy', 'docker-socket-proxy-writable', 'docker-socket-proxy-published',
'docker-socket-proxy-mutating',
'docker-socket-proxy-exposure', 'docker-socket-proxy-client',
'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck',
'healthcheck-disabled', 'healthcheck-inherited', 'healthcheck-unverifiable', 'healthcheck-inconsistent',
'deploy-swarm-only',
@@ -132,7 +132,8 @@ describe('buildMounts', () => {
{ type: 'bind', source: '/app/stack/conf', target: '/conf', readOnly: true },
{ type: 'named', source: 'shared', target: '/s', readOnly: false },
],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active',
envKeys: [], enabledProxyApiFlags: [], dockerEndpointHosts: [], networks: [], extraHosts: [], labelKeys: [],
},
],
networks: {},
@@ -161,7 +162,8 @@ describe('assembleStorageInventory', () => {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active',
envKeys: [], enabledProxyApiFlags: [], dockerEndpointHosts: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', stateful, null, new Map()).stateful).toBe(true);
@@ -178,7 +180,8 @@ describe('assembleStorageInventory', () => {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false }],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active',
envKeys: [], enabledProxyApiFlags: [], dockerEndpointHosts: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', socketOnly, null, new Map()).stateful).toBe(false);