mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
bb98cba1f2
* 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.
309 lines
9.6 KiB
TypeScript
309 lines
9.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { deriveStackExposure, buildExposedImageMap, type StackExposure } from '../services/preflight/exposure';
|
|
import type { EffectiveModel } from '../services/preflight/effectiveModel';
|
|
|
|
function svc(overrides: Record<string, unknown>) {
|
|
return {
|
|
name: 'app',
|
|
image: 'nginx:latest',
|
|
ports: [] as Array<{ startPort: number; endPort: number; hostIp: string; protocol: string }>,
|
|
binds: [],
|
|
namedVolumes: [],
|
|
storageMounts: [],
|
|
privileged: false,
|
|
networkMode: undefined as string | undefined,
|
|
restart: undefined as string | undefined,
|
|
hasHealthcheck: false,
|
|
composeHealthcheck: 'absent' as const,
|
|
envKeys: [],
|
|
enabledProxyApiFlags: [],
|
|
dockerEndpointHosts: [],
|
|
networks: [],
|
|
extraHosts: [],
|
|
labelKeys: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function model(overrides: Partial<EffectiveModel>): EffectiveModel {
|
|
return {
|
|
projectName: 'test',
|
|
services: [],
|
|
networks: {},
|
|
volumes: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const NOW = 1700000000000;
|
|
|
|
describe('deriveStackExposure', () => {
|
|
it('marks a service with no ports and no host networking as not exposed', () => {
|
|
const m = model({ services: [svc({ image: 'nginx:latest' })] });
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(false);
|
|
expect(r.services[0].reason).toBeNull();
|
|
});
|
|
|
|
it('marks a service publishing on 0.0.0.0 as exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true);
|
|
expect(r.services[0].reason).toBe('published-port');
|
|
expect(r.services[0].bindings).toEqual(['0.0.0.0:8080/tcp']);
|
|
});
|
|
|
|
it('marks a service publishing on :: (IPv6 all-interfaces) as exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 3000, endPort: 3000, hostIp: '::', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true);
|
|
});
|
|
|
|
it('marks a service publishing on an empty host IP as exposed (Docker default = all interfaces)', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 5432, endPort: 5432, hostIp: '', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true);
|
|
});
|
|
|
|
it('marks a service publishing on a specific LAN IP as exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 8080, endPort: 8080, hostIp: '192.168.1.50', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true);
|
|
});
|
|
|
|
it('keeps a loopback-only service as not exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.1', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(false);
|
|
});
|
|
|
|
it('marks ::1 (IPv6 loopback) as not exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 8080, endPort: 8080, hostIp: '::1', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(false);
|
|
});
|
|
|
|
it('marks any 127.0.0.0/8 address as loopback (not exposed)', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.2', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(false);
|
|
});
|
|
|
|
it('marks a host-network service as exposed even with no published ports', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({ networkMode: 'host' }),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true);
|
|
expect(r.services[0].reason).toBe('host-network');
|
|
expect(r.services[0].bindings).toEqual([]);
|
|
});
|
|
|
|
it('does not mark network_mode: none as exposed', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({ networkMode: 'none' }),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(false);
|
|
});
|
|
|
|
it('carries the image reference through for downstream joins', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
image: 'postgres:15',
|
|
ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].image).toBe('postgres:15');
|
|
});
|
|
|
|
it('sets image to null for build-only services', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
image: undefined,
|
|
ports: [{ startPort: 3000, endPort: 3000, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].image).toBeNull();
|
|
expect(r.services[0].publiclyExposed).toBe(true); // still exposed via port
|
|
});
|
|
|
|
it('handles multi-service stacks with mixed exposure', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
name: 'frontend',
|
|
ports: [{ startPort: 80, endPort: 80, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
|
}),
|
|
svc({ name: 'backend', ports: [{ startPort: 4000, endPort: 4000, hostIp: '127.0.0.1', protocol: 'tcp' }] }),
|
|
svc({ name: 'metrics', networkMode: 'host' }),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].publiclyExposed).toBe(true); // frontend
|
|
expect(r.services[1].publiclyExposed).toBe(false); // backend (loopback)
|
|
expect(r.services[2].publiclyExposed).toBe(true); // metrics (host network)
|
|
});
|
|
|
|
it('includes the stack name and timestamp in the descriptor', () => {
|
|
const m = model({ services: [svc({})] });
|
|
const r = deriveStackExposure(m, 'mystack', NOW);
|
|
expect(r.stack).toBe('mystack');
|
|
expect(r.computedAt).toBe(NOW);
|
|
});
|
|
|
|
it('produces bindings in host-only format without container target ports', () => {
|
|
const m = model({
|
|
services: [
|
|
svc({
|
|
ports: [
|
|
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
|
{ startPort: 9000, endPort: 9001, hostIp: '', protocol: 'udp' },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
const r = deriveStackExposure(m, 'test', NOW);
|
|
expect(r.services[0].bindings).toEqual([
|
|
'0.0.0.0:8080/tcp',
|
|
'0.0.0.0:9000-9001/udp',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('buildExposedImageMap', () => {
|
|
function exp(stack: string, services: Array<{ image: string | null; publiclyExposed: boolean }>): StackExposure {
|
|
return {
|
|
stack,
|
|
computedAt: NOW,
|
|
services: services.map((s) => ({
|
|
service: 's',
|
|
image: s.image,
|
|
publiclyExposed: s.publiclyExposed,
|
|
reason: s.publiclyExposed ? 'published-port' : null,
|
|
bindings: [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
it('returns an empty map for no exposures', () => {
|
|
expect(buildExposedImageMap([]).size).toBe(0);
|
|
});
|
|
|
|
it('maps an exposed image to true', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
|
]);
|
|
expect(map.get('nginx:latest')).toBe(true);
|
|
});
|
|
|
|
it('maps an internal-only image to false', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
|
]);
|
|
expect(map.get('nginx:latest')).toBe(false);
|
|
});
|
|
|
|
it('skips build-only services (no image)', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: null, publiclyExposed: true }]),
|
|
]);
|
|
expect(map.has(null as unknown as string)).toBe(false);
|
|
expect(map.size).toBe(0);
|
|
});
|
|
|
|
it('true wins over false when the same image appears in multiple stacks', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
|
exp('b', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
|
]);
|
|
expect(map.get('nginx:latest')).toBe(true);
|
|
});
|
|
|
|
it('true stays true even when a later stack classifies the image internal', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
|
exp('b', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
|
]);
|
|
expect(map.get('nginx:latest')).toBe(true);
|
|
});
|
|
|
|
it('returns false when the image appears only as internal across all stacks', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'postgres:15', publiclyExposed: false }]),
|
|
exp('b', [{ image: 'postgres:15', publiclyExposed: false }]),
|
|
]);
|
|
expect(map.get('postgres:15')).toBe(false);
|
|
});
|
|
|
|
it('leaves an absent image as undefined (no descriptor contains it)', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [{ image: 'redis:7', publiclyExposed: true }]),
|
|
]);
|
|
expect(map.get('nginx:latest')).toBeUndefined();
|
|
});
|
|
|
|
it('handles mixed images in the same stack', () => {
|
|
const map = buildExposedImageMap([
|
|
exp('a', [
|
|
{ image: 'frontend:1', publiclyExposed: true },
|
|
{ image: 'backend:1', publiclyExposed: false },
|
|
]),
|
|
]);
|
|
expect(map.get('frontend:1')).toBe(true);
|
|
expect(map.get('backend:1')).toBe(false);
|
|
});
|
|
});
|