mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +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,
|
||||
|
||||
Reference in New Issue
Block a user