mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Parser for the output of `docker compose config` (the fully-resolved
|
||||
* effective model). It extracts only the STRUCTURAL facts the preflight rules
|
||||
* need; it never retains an environment VALUE. Service environment is read for
|
||||
* its key NAMES only (to detect PUID/PGID style directives), and render errors
|
||||
* are handled by the caller, not here.
|
||||
* effective model). It keeps structural facts for preflight and related
|
||||
* consumers; it never retains an environment VALUE. Service environment is
|
||||
* read for its key NAMES (to detect PUID/PGID style directives) and, for a
|
||||
* finite whitelist of Docker socket-proxy API flags, whether the rendered
|
||||
* value is exactly `1` (stored as enabled flag names only). `command`,
|
||||
* `entrypoint`, and `DOCKER_HOST` are read for `tcp://` endpoint host names
|
||||
* only. Render errors are handled by the caller, not here.
|
||||
*/
|
||||
|
||||
import { classifyComposeHealthcheck } from '../../helpers/healthcheckPresence';
|
||||
@@ -70,6 +73,20 @@ export interface EffService {
|
||||
user?: string;
|
||||
/** Environment KEY names only. Values are never extracted. */
|
||||
envKeys: string[];
|
||||
/**
|
||||
* Names of recognized Docker socket-proxy API flags whose rendered value is
|
||||
* exactly `1` (after trim). Matches the upstream images, which grant a group
|
||||
* only for the literal value `1`. Raw values are never retained.
|
||||
*/
|
||||
enabledProxyApiFlags: string[];
|
||||
/**
|
||||
* Host names of `tcp://host[:port]` endpoints referenced by `command`,
|
||||
* `entrypoint`, or a `DOCKER_HOST` environment value. Used to spot services
|
||||
* pointed at a socket proxy. Only the host name is retained; any
|
||||
* `user:pass@` prefix is dropped. Non-tcp schemes (unix://, npipe://) and
|
||||
* empty values contribute nothing.
|
||||
*/
|
||||
dockerEndpointHosts: string[];
|
||||
/** Network membership by network key, with any aliases. */
|
||||
networks: EffServiceNetwork[];
|
||||
/** `extra_hosts` entries as `host:value` strings (host names / static IPs; a value built from a `${VAR}` is resolved upstream by `docker compose config`, so it can carry an interpolated secret). */
|
||||
@@ -288,6 +305,79 @@ function envKeysOf(env: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit each environment key/value pair without retaining values. Handles both
|
||||
* map form (`{ KEY: value }`) and array form (`["KEY=value"]`).
|
||||
*/
|
||||
function forEachEnvEntry(env: unknown, visit: (key: string, value: unknown) => void): void {
|
||||
if (Array.isArray(env)) {
|
||||
for (const entry of env) {
|
||||
const s = str(entry);
|
||||
if (s === undefined) continue;
|
||||
const eq = s.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
visit(s.slice(0, eq), s.slice(eq + 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (env && typeof env === 'object') {
|
||||
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
|
||||
visit(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker socket-proxy API group / verb env keys recognized for topology and
|
||||
* mutation detection. Only names whose rendered value is exactly `1` are kept
|
||||
* on the model (see `enabledProxyApiFlagsOf`).
|
||||
*/
|
||||
const PROXY_API_FLAG_KEYS: ReadonlySet<string> = new Set([
|
||||
'CONTAINERS', 'IMAGES', 'INFO', 'EVENTS', 'NETWORKS', 'VOLUMES', 'POST', 'DELETE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whitelisted proxy API flag names whose rendered value is exactly `1` (after
|
||||
* trim). Both tecnativa/docker-socket-proxy and linuxserver/socket-proxy grant
|
||||
* a group only for that literal; values like `true`, `yes`, or arbitrary
|
||||
* strings still 403. Inspects values only long enough to decide enablement;
|
||||
* never returns them.
|
||||
*/
|
||||
function enabledProxyApiFlagsOf(env: unknown): string[] {
|
||||
const enabled = new Set<string>();
|
||||
forEachEnvEntry(env, (key, raw) => {
|
||||
if (!PROXY_API_FLAG_KEYS.has(key)) return;
|
||||
if (str(raw)?.trim() === '1') enabled.add(key);
|
||||
});
|
||||
return [...enabled];
|
||||
}
|
||||
|
||||
/** Matches a `tcp://[user:pass@]host[:port]` endpoint, capturing the host only. */
|
||||
const TCP_ENDPOINT_RE = /tcp:\/\/(?:[^/@\s]*@)?([A-Za-z0-9._-]+)/g;
|
||||
|
||||
/**
|
||||
* Host names of any `tcp://host[:port]` endpoint referenced by a service's
|
||||
* `command`, `entrypoint`, or `DOCKER_HOST`. Only the host name is retained;
|
||||
* credentials and the rest of the argument are dropped. Non-tcp schemes and
|
||||
* empty values contribute nothing.
|
||||
*/
|
||||
function dockerEndpointHostsOf(command: unknown, entrypoint: unknown, env: unknown): string[] {
|
||||
const hosts = new Set<string>();
|
||||
function addFrom(source: unknown): void {
|
||||
for (const item of [source].flat()) {
|
||||
const s = str(item);
|
||||
if (s === undefined) continue;
|
||||
for (const match of s.matchAll(TCP_ENDPOINT_RE)) hosts.add(match[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
addFrom(command);
|
||||
addFrom(entrypoint);
|
||||
forEachEnvEntry(env, (key, value) => {
|
||||
if (key === 'DOCKER_HOST' && value !== undefined) addFrom(value);
|
||||
});
|
||||
return [...hosts];
|
||||
}
|
||||
|
||||
/** Label KEY names only. A label VALUE can carry a secret, so it is never read. */
|
||||
function labelKeysOf(labels: unknown): string[] {
|
||||
if (Array.isArray(labels)) {
|
||||
@@ -418,6 +508,8 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
|
||||
containerName: str(svc.container_name),
|
||||
user: str(svc.user),
|
||||
envKeys: envKeysOf(svc.environment),
|
||||
enabledProxyApiFlags: enabledProxyApiFlagsOf(svc.environment),
|
||||
dockerEndpointHosts: dockerEndpointHostsOf(svc.command, svc.entrypoint, svc.environment),
|
||||
networks: parseServiceNetworks(svc.networks),
|
||||
extraHosts: parseExtraHosts(svc.extra_hosts),
|
||||
labelKeys: labelKeysOf(svc.labels),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
|
||||
import type { EffService, EffPortSpec } from './effectiveModel';
|
||||
import type { EffService, EffPortSpec, EffectiveModel } from './effectiveModel';
|
||||
import type { ExposureIntent } from '../network/types';
|
||||
import { isLoopback, runtimeResourceName } from '../network/normalize';
|
||||
import { classifyMissingExternalNetworks } from '../network/missingExternalNetworks';
|
||||
@@ -13,6 +13,7 @@ export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warni
|
||||
*/
|
||||
export const PREFLIGHT_NOTE_RULE_IDS: ReadonlySet<string> = new Set([
|
||||
'healthcheck-inherited',
|
||||
'docker-socket-proxy-client',
|
||||
]);
|
||||
|
||||
export function isPreflightNoteFinding(ruleId: string): boolean {
|
||||
@@ -65,6 +66,99 @@ function hasUidGidSignal(svc: EffService): boolean {
|
||||
return svc.user !== undefined || svc.envKeys.some(k => UID_GID_KEYS.has(k));
|
||||
}
|
||||
|
||||
function pathIsDockerSocket(path: string | undefined): boolean {
|
||||
return path !== undefined && path.includes('docker.sock');
|
||||
}
|
||||
|
||||
function mountTouchesDockerSocket(source: string | undefined, target: string | undefined): boolean {
|
||||
return pathIsDockerSocket(source) || pathIsDockerSocket(target);
|
||||
}
|
||||
|
||||
/** Bind mounts that touch the Docker socket (from the storage inventory). */
|
||||
function dockerSocketBinds(svc: EffService) {
|
||||
return (svc.storageMounts ?? []).filter(m =>
|
||||
m.type === 'bind' && mountTouchesDockerSocket(m.source, m.target));
|
||||
}
|
||||
|
||||
function mountsDockerSocket(svc: EffService): boolean {
|
||||
if (svc.binds.some(b => mountTouchesDockerSocket(b.source, b.target))) return true;
|
||||
return dockerSocketBinds(svc).length > 0;
|
||||
}
|
||||
|
||||
function hasReadOnlyDockerSocket(svc: EffService): boolean {
|
||||
return dockerSocketBinds(svc).some(m => m.readOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any socket mount is writable. Deliberately not the negation of
|
||||
* `hasReadOnlyDockerSocket`: a service can mount the socket twice, and one
|
||||
* read-only mount does not make a second writable one safe.
|
||||
*/
|
||||
function hasWritableDockerSocket(svc: EffService): boolean {
|
||||
return dockerSocketBinds(svc).some(m => !m.readOnly);
|
||||
}
|
||||
|
||||
const SOCKET_PROXY_IMAGE_HINTS = [
|
||||
'tecnativa/docker-socket-proxy',
|
||||
'lscr.io/linuxserver/socket-proxy',
|
||||
'docker-socket-proxy',
|
||||
] as const;
|
||||
|
||||
const MUTATING_PROXY_API_FLAGS = new Set(['POST', 'DELETE']);
|
||||
|
||||
function hasSocketProxyImage(svc: EffService): boolean {
|
||||
const image = svc.image?.toLowerCase();
|
||||
return image !== undefined && SOCKET_PROXY_IMAGE_HINTS.some(h => image.includes(h));
|
||||
}
|
||||
|
||||
function hasSocketProxyNameHint(svc: EffService): boolean {
|
||||
// Covers `socket-proxy`, `docker-socket-proxy`, and `_`/`.` separated variants.
|
||||
return `${svc.name} ${svc.containerName ?? ''}`
|
||||
.toLowerCase()
|
||||
.replace(/[_.]/g, '-')
|
||||
.includes('socket-proxy');
|
||||
}
|
||||
|
||||
/** Names a dependent service could use to reach this proxy on a shared network. */
|
||||
function proxyReachableNames(svc: EffService): string[] {
|
||||
return [svc.name, svc.containerName, ...svc.networks.flatMap(n => n.aliases)]
|
||||
.filter((n): n is string => n !== undefined)
|
||||
.map(n => n.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated Docker socket proxy. A known image is an artifact identity, so it
|
||||
* stands on its own. A service NAME is free text the author controls, so it
|
||||
* only counts alongside an observable fact: a read-only socket or an enabled
|
||||
* API group flag (value exactly `1`). Without a name or image hint, both are
|
||||
* required. Prefer false negatives here, since a miss keeps the high
|
||||
* direct-mount finding.
|
||||
*/
|
||||
function isSocketProxyService(svc: EffService): boolean {
|
||||
if (!mountsDockerSocket(svc)) return false;
|
||||
if (hasSocketProxyImage(svc)) return true;
|
||||
const readOnly = hasReadOnlyDockerSocket(svc);
|
||||
// Only flags whose rendered value is exactly `1` are counted; key presence alone is not.
|
||||
const apiKeyCount = (svc.enabledProxyApiFlags ?? []).length;
|
||||
if (hasSocketProxyNameHint(svc)) return readOnly || apiKeyCount >= 1;
|
||||
return readOnly && apiKeyCount >= 2;
|
||||
}
|
||||
|
||||
function socketProxyServices(model: EffectiveModel): EffService[] {
|
||||
return model.services.filter(isSocketProxyService);
|
||||
}
|
||||
|
||||
/**
|
||||
* True unless every network the proxy joins is declared `internal: true`. A
|
||||
* service with no explicit membership lands on the implicit default network,
|
||||
* and a network the model does not describe cannot be shown to be internal, so
|
||||
* both count as non-internal.
|
||||
*/
|
||||
function proxyAttachesNonInternalNetwork(svc: EffService, model: EffectiveModel): boolean {
|
||||
if (svc.networks.length === 0) return true;
|
||||
return svc.networks.some(membership => model.networks[membership.key]?.internal !== true);
|
||||
}
|
||||
|
||||
// ----- rules ----------------------------------------------------------------
|
||||
|
||||
const renderFailed: PreflightRule = {
|
||||
@@ -271,21 +365,153 @@ const dockerSocketMount: PreflightRule = {
|
||||
id: 'docker-socket-mount',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
for (const svc of ctx.model.services) {
|
||||
const hit = svc.binds.some(b => b.source.includes('docker.sock') || b.target.includes('docker.sock'));
|
||||
if (!hit) continue;
|
||||
findings.push({
|
||||
const proxies = socketProxyServices(ctx.model);
|
||||
// When the stack already runs a proxy, point at it rather than suggesting
|
||||
// the user adopt a mitigation they have implemented.
|
||||
const remediation = proxies.length > 0
|
||||
? `This stack already runs a socket proxy (${proxies.map(p => `"${p.name}"`).join(', ')}); route this service through it instead of mounting docker.sock directly.`
|
||||
: 'Avoid direct socket mounts when possible; consider using a scoped socket proxy.';
|
||||
return ctx.model.services
|
||||
.filter(svc => mountsDockerSocket(svc) && !isSocketProxyService(svc))
|
||||
.map(svc => ({
|
||||
ruleId: 'docker-socket-mount',
|
||||
severity: 'high',
|
||||
severity: 'high' as const,
|
||||
title: 'Docker socket mounted',
|
||||
message: `Service "${svc.name}" mounts the Docker socket, which grants it root-equivalent control over the host.`,
|
||||
message: `Service "${svc.name}" mounts the Docker socket directly, granting broad control over the Docker host.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Avoid mounting docker.sock unless required; consider a scoped socket proxy.',
|
||||
});
|
||||
remediation,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxyWritable: PreflightRule = {
|
||||
id: 'docker-socket-proxy-writable',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return socketProxyServices(ctx.model)
|
||||
.filter(hasWritableDockerSocket)
|
||||
.map(svc => ({
|
||||
ruleId: 'docker-socket-proxy-writable',
|
||||
severity: 'high' as const,
|
||||
title: 'Docker socket proxy mounts the socket read-write',
|
||||
message: `Service "${svc.name}" looks like a Docker socket proxy but mounts the Docker socket read-write, so a compromise of the proxy grants full control over the Docker host.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Mount the socket read-only (append `:ro` to the bind) and let the proxy restrict which API groups dependents can reach.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxy: PreflightRule = {
|
||||
id: 'docker-socket-proxy',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return socketProxyServices(ctx.model).map(svc => ({
|
||||
ruleId: 'docker-socket-proxy',
|
||||
severity: 'info' as const,
|
||||
title: 'Docker socket proxy detected',
|
||||
message: `Service "${svc.name}" intentionally mounts the Docker socket because it is acting as a socket proxy. Verify that only trusted services can reach the proxy and that the enabled Docker API groups are limited to what dependent services require.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Keep the proxy on an internal network, avoid publishing its API port to the host, and enable only the API groups dependents need.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxyPublished: PreflightRule = {
|
||||
id: 'docker-socket-proxy-published',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return socketProxyServices(ctx.model)
|
||||
.filter(svc => svc.ports.length > 0)
|
||||
.map(svc => ({
|
||||
ruleId: 'docker-socket-proxy-published',
|
||||
severity: 'high' as const,
|
||||
title: 'Docker socket proxy publishes a host port',
|
||||
message: `Service "${svc.name}" is a Docker socket proxy and publishes ${svc.ports.map(specLabel).join(', ')} to the host, which can make the proxy reachable beyond the Compose network.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Remove host port mappings from the proxy and let dependents reach it only over an internal Compose network.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxyMutating: PreflightRule = {
|
||||
id: 'docker-socket-proxy-mutating',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return socketProxyServices(ctx.model).flatMap(svc => {
|
||||
const mutating = (svc.enabledProxyApiFlags ?? []).filter(f => MUTATING_PROXY_API_FLAGS.has(f));
|
||||
if (mutating.length === 0) return [];
|
||||
return [{
|
||||
ruleId: 'docker-socket-proxy-mutating',
|
||||
severity: 'warning' as const,
|
||||
title: 'Docker socket proxy allows mutating API access',
|
||||
message: `Service "${svc.name}" enables ${mutating.join(' and ')} on the Docker socket proxy, which permits write and delete operations through the proxy.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Disable POST and DELETE on the proxy unless dependents require mutating Docker API calls.',
|
||||
}];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxyExposure: PreflightRule = {
|
||||
id: 'docker-socket-proxy-exposure',
|
||||
run(ctx) {
|
||||
const model = ctx.model;
|
||||
if (!model) return [];
|
||||
return socketProxyServices(model)
|
||||
.filter(svc => proxyAttachesNonInternalNetwork(svc, model))
|
||||
.map(svc => ({
|
||||
ruleId: 'docker-socket-proxy-exposure',
|
||||
severity: 'warning' as const,
|
||||
title: 'Docker socket proxy on a non-internal network',
|
||||
message: `Service "${svc.name}" is a Docker socket proxy attached to at least one network that is not marked internal, which widens who can reach the proxied Docker API.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Attach the proxy only to internal Compose networks used by trusted dependents.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const dockerSocketProxyClient: PreflightRule = {
|
||||
id: 'docker-socket-proxy-client',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
const proxies = socketProxyServices(ctx.model);
|
||||
if (proxies.length === 0) return [];
|
||||
|
||||
// Per-proxy correlation: the client must name a reachable identity of a
|
||||
// specific proxy AND share a network with that same proxy. Aggregate
|
||||
// name/network sets would false-positive when a client on proxy-A's
|
||||
// network points at unreachable proxy-B.
|
||||
//
|
||||
// Hosts come from `DOCKER_HOST` and from `tcp://` endpoints on command /
|
||||
// entrypoint (how Traefik and friends point at a proxy). Presence of the
|
||||
// `DOCKER_HOST` key alone is not enough; only a tcp host that matches.
|
||||
function isClientOf(svc: EffService, proxy: EffService): boolean {
|
||||
const names = new Set(proxyReachableNames(proxy));
|
||||
if (!svc.dockerEndpointHosts.some(host => names.has(host))) return false;
|
||||
// Empty membership = implicit default network. Only match when both are on it.
|
||||
if (svc.networks.length === 0 || proxy.networks.length === 0) {
|
||||
return svc.networks.length === 0 && proxy.networks.length === 0;
|
||||
}
|
||||
const proxyNets = new Set(proxy.networks.map(n => n.key));
|
||||
return svc.networks.some(n => proxyNets.has(n.key));
|
||||
}
|
||||
return findings;
|
||||
|
||||
return ctx.model.services
|
||||
.filter(svc => !mountsDockerSocket(svc) && proxies.some(proxy => isClientOf(svc, proxy)))
|
||||
.map(svc => ({
|
||||
ruleId: 'docker-socket-proxy-client',
|
||||
severity: 'info' as const,
|
||||
title: 'Docker API access routed through socket proxy',
|
||||
message: `Service "${svc.name}" does not mount docker.sock directly and appears to use a Docker socket proxy instead.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -896,6 +1122,12 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
bindPathMissing,
|
||||
bindPathPermission,
|
||||
dockerSocketMount,
|
||||
dockerSocketProxy,
|
||||
dockerSocketProxyWritable,
|
||||
dockerSocketProxyPublished,
|
||||
dockerSocketProxyMutating,
|
||||
dockerSocketProxyExposure,
|
||||
dockerSocketProxyClient,
|
||||
privileged,
|
||||
networkModeHost,
|
||||
uidGidRisk,
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Run a preflight check on a stack before you deploy. Compose Doctor
|
||||
|
||||
The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to.
|
||||
|
||||
The check is advisory: on its own it never blocks a deploy or changes a stack. (One rule is the exception: see [Self-management](#self-management) below for the one case where Sencho actively blocks an action, independent of this report.) It runs on demand: press **run preflight** and Sencho renders the model, runs all 36 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
The check is advisory: on its own it never blocks a deploy or changes a stack. (One rule is the exception: see [Self-management](#self-management) below for the one case where Sencho actively blocks an action, independent of this report.) It runs on demand: press **run preflight** and Sencho renders the model, runs all 42 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -24,7 +24,7 @@ Every preflight run follows three steps:
|
||||
|
||||
1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model.
|
||||
2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node.
|
||||
3. **Run 36 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
3. **Run 42 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
|
||||
Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history.
|
||||
|
||||
@@ -73,7 +73,7 @@ A small colored dot appears on the **Doctor** tab label when the last run's acti
|
||||
|
||||
## What it checks
|
||||
|
||||
All 36 rules are listed below, organized by topic.
|
||||
All 42 rules are listed below, organized by topic.
|
||||
|
||||
### Model rendering
|
||||
|
||||
@@ -108,7 +108,13 @@ All 36 rules are listed below, organized by topic.
|
||||
|
||||
| Rule | Severity | What it detects |
|
||||
|------|----------|----------------|
|
||||
| Docker socket mounted | High | A service mounts `docker.sock`, which grants it root-equivalent control over the host. |
|
||||
| Docker socket mounted | High | A service mounts `docker.sock` directly, granting broad control over the Docker host. When the stack already runs a socket proxy, the fix names that proxy instead of suggesting you adopt one. |
|
||||
| Docker socket proxy detected | Info | A dedicated Docker socket proxy service mounts `docker.sock`. A known proxy image is enough on its own; a proxy-shaped service name counts only alongside a read-only socket or an enabled API group flag (value exactly `1`), and without either hint the service needs a read-only socket and at least two enabled API group flags. Verify trust boundaries and enabled API groups. |
|
||||
| Docker socket proxy mounts the socket read-write | High | A service classified as a socket proxy mounts `docker.sock` without `:ro`, so compromising the proxy grants full control over the Docker host. |
|
||||
| Docker socket proxy publishes a host port | High | A classified socket proxy publishes any host port, which can make the proxy reachable beyond the Compose network. |
|
||||
| Docker socket proxy allows mutating API access | Warning | A classified socket proxy enables `POST` and/or `DELETE` on the proxied Docker API (flag value exactly `1`). |
|
||||
| Docker socket proxy on a non-internal network | Warning | A classified socket proxy joins a network that is not declared `internal`, including the implicit default network and any network the rendered model does not describe. |
|
||||
| Docker API access routed through socket proxy | Note | A service does not mount `docker.sock`, shares a network with a classified proxy, and points at that same proxy through a `tcp://` host in `DOCKER_HOST`, `command`, or `entrypoint`. Coverage is treated as satisfied for All Clear; the note explains the topology. |
|
||||
| Privileged container | High | A service runs with `privileged: true`, disabling most container isolation. |
|
||||
| Host network mode | High | A service uses `network_mode: host`, bypassing Docker's network isolation and ignoring published-port mappings. |
|
||||
| Check UID/GID alignment | Warning | A service sets a UID or GID and mounts host paths that Sencho cannot inspect from inside its container. Mismatched ownership between the host path and the container user is a common source of permission errors. |
|
||||
@@ -167,7 +173,7 @@ These rules activate when the stack publishes at least one host port. They use t
|
||||
|
||||
## Exposure intent checks
|
||||
|
||||
Five of the 36 rules cross-reference the stack's exposure intent and the access URLs documented in the Stack Dossier. These rules only fire when the stack publishes at least one host port.
|
||||
The five rules in the [Exposure intent](#exposure-intent) category above cross-reference the stack's exposure intent and the access URLs documented in the Stack Dossier. These rules only fire when the stack publishes at least one host port.
|
||||
|
||||
To resolve exposure-related findings:
|
||||
|
||||
@@ -190,7 +196,7 @@ The networking-relevant rules on this page (host mode, exposure intent, port con
|
||||
|
||||
## Node-state checks and graceful degradation
|
||||
|
||||
Six of the 36 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable. Healthcheck inheritance checks also read Docker when Compose does not declare a healthcheck; those degrade to an Info finding when the daemon or image is unavailable.
|
||||
Six of the 42 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable. Healthcheck inheritance checks also read Docker when Compose does not declare a healthcheck; those degrade to an Info finding when the daemon or image is unavailable.
|
||||
|
||||
When the daemon is unreachable:
|
||||
|
||||
|
||||
@@ -163,6 +163,28 @@ describe('PreflightPanel', () => {
|
||||
expect(screen.queryByTestId('preflight-ack-btn-healthcheck-inherited-web')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders socket-proxy client findings as Notes without acknowledgement', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'pass',
|
||||
activeStatus: 'pass',
|
||||
activeCount: 0,
|
||||
findings: [{
|
||||
ruleId: 'docker-socket-proxy-client',
|
||||
severity: 'info',
|
||||
title: 'Docker API access routed through socket proxy',
|
||||
message: 'Service "app" does not mount docker.sock directly and appears to use a Docker socket proxy instead.',
|
||||
service: 'app',
|
||||
}],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" canEdit />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'pass');
|
||||
expect(status).toHaveTextContent(/all clear/i);
|
||||
expect(screen.getByTestId('preflight-notes-section')).toHaveTextContent(/Docker API access routed through socket proxy/i);
|
||||
expect(screen.queryByTestId('preflight-ack-btn-docker-socket-proxy-client-app')).not.toBeInTheDocument();
|
||||
expect(status).not.toHaveTextContent(/info/i);
|
||||
});
|
||||
|
||||
it('excludes notes from the graded summary line when issue findings remain', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'warning',
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Informational Compose Doctor notes (excluded from All Clear, severity
|
||||
* summary, and dismiss fingerprint). Keep in sync with backend PREFLIGHT_NOTE_RULE_IDS.
|
||||
*/
|
||||
const PREFLIGHT_NOTE_RULE_IDS = new Set(['healthcheck-inherited']);
|
||||
const PREFLIGHT_NOTE_RULE_IDS = new Set([
|
||||
'healthcheck-inherited',
|
||||
'docker-socket-proxy-client',
|
||||
]);
|
||||
|
||||
export function isPreflightNoteFinding(ruleId: string | undefined): boolean {
|
||||
return !!ruleId && PREFLIGHT_NOTE_RULE_IDS.has(ruleId);
|
||||
|
||||
Reference in New Issue
Block a user