From 30fb770a9f37a2414c34d0acd14f1fa289576d18 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sat, 22 Aug 2026 17:11:57 +0200 Subject: [PATCH] compose validate: recognize socket proxies and add exposure/volume/deploy rules --- src/lib/server/compose-validate/rules.ts | 424 ++++++++++++++++++++++- tests/compose-validate/rules.test.ts | 174 ++++++++++ 2 files changed, 587 insertions(+), 11 deletions(-) diff --git a/src/lib/server/compose-validate/rules.ts b/src/lib/server/compose-validate/rules.ts index 50ad273..48305ca 100644 --- a/src/lib/server/compose-validate/rules.ts +++ b/src/lib/server/compose-validate/rules.ts @@ -79,6 +79,13 @@ function toPort(s: string): number | null { const DB_CACHE_IMAGE = /(?:^|\/)(postgres|mysql|mariadb|mongo|redis|memcached|rabbitmq|elasticsearch|clickhouse|cassandra|couchdb|influxdb)(?::|$)/i; +// Admin / management UIs that expose control over data or the host. Broadly +// published (0.0.0.0) these are worse than a password-protected datastore: many +// ship with a default or no login. Kept DISJOINT from DB_CACHE_IMAGE so a given +// service produces at most one exposure finding (datastore=warn, panel=error). +const ADMIN_PANEL_IMAGE = + /(?:^|\/)(adminer|phpmyadmin|pgadmin\d?|portainer(?:-ce|-ee)?|cloudbeaver|mongo-express|redis-?commander|dpage\/pgadmin\d?)(?::|$)/i; + // Env-var NAMES that look like a secret (matched case-insensitively as a whole word part). // Secret-looking env NAMES. Anchored on word/underscore boundaries so a secret word // must be a whole segment (matches DB_PASSWORD, API_KEY; not TOKENIZED, PASSWORD_POLICY- @@ -89,6 +96,11 @@ const SECRET_NAME = // (Docker/compose secrets), so don't flag those even if the name contains "password". const SECRET_FILE_REF = /(_file|_path)$/i; +// Dockhand's own image (any registry prefix / tag). A stack deploying Dockhand +// legitimately needs the Docker socket, so the socket-mount rule treats it as +// info, not a misconfiguration. +const DOCKHAND_IMAGE = /(?:^|\/)(?:fin?sys\/)?dockhand(?::|$)/i; + // Host paths that must never be bind-mounted writable - a rw mount here = own the host. const SENSITIVE_HOST_ROOTS = ['/', '/etc', '/usr', '/bin', '/sbin', '/boot', '/lib', '/var', '/root']; @@ -140,6 +152,141 @@ function bindSource(v: unknown): { source: string; readonly: boolean } | null { return null; } +// --- Docker socket-proxy classifier (adapted from sencho #1791) ------------------ +// +// A well-configured docker-socket-proxy sidecar is intentional, not a footgun, so +// it must not be flagged like a direct socket mount. These pure helpers read the +// RAW parsed compose (we have no rendered effective model in this linter), so +// per-network `internal:` is read literally from the top-level networks: block. + +const SOCKET_PROXY_IMAGE_HINTS = [ + 'tecnativa/docker-socket-proxy', + 'lscr.io/linuxserver/socket-proxy', + 'docker-socket-proxy' +]; + +// docker-socket-proxy API-group env flags. A group is "enabled" only when its +// rendered value is exactly `1` (proxy convention); key presence alone is not. +const PROXY_API_FLAGS = new Set([ + 'CONTAINERS', 'IMAGES', 'INFO', 'EVENTS', 'NETWORKS', 'VOLUMES', 'SERVICES', + 'TASKS', 'NODES', 'SECRETS', 'CONFIGS', 'SWARM', 'SYSTEM', 'BUILD', 'COMMIT', + 'DISTRIBUTION', 'EXEC', 'PING', 'PLUGINS', 'SESSION', 'POST', 'DELETE' +]); +const MUTATING_PROXY_API_FLAGS = new Set(['POST', 'DELETE']); + +/** All docker.sock bind mounts of a service (short or long form). */ +function socketBinds(svc: Record): { readonly: boolean }[] { + const vols = Array.isArray(svc.volumes) ? svc.volumes : []; + const out: { readonly: boolean }[] = []; + for (const v of vols) { + const src = typeof v === 'string' ? v : asRecord(v)?.source; + if (typeof src === 'string' && src.includes('docker.sock')) { + const raw = typeof v === 'string' ? v : ''; + const parts = raw.split(':'); + const readonly = + (parts.length >= 3 && optsAreReadonly(parts[parts.length - 1])) || + asRecord(v)?.read_only === true; + out.push({ readonly }); + } + } + return out; +} + +function mountsDockerSocket(svc: Record): boolean { + return socketBinds(svc).length > 0; +} + +/** The enabled proxy API-group flag NAMES (value exactly `1`). */ +function enabledProxyApiFlags(svc: Record): string[] { + return envEntries(svc) + .filter((e) => PROXY_API_FLAGS.has(e.key.toUpperCase()) && e.value?.trim() === '1') + .map((e) => e.key.toUpperCase()); +} + +function hasSocketProxyImage(svc: Record): boolean { + const image = typeof svc.image === 'string' ? svc.image.toLowerCase() : ''; + return SOCKET_PROXY_IMAGE_HINTS.some((h) => image.includes(h)); +} + +function hasSocketProxyNameHint(name: string, svc: Record): boolean { + const cn = typeof svc.container_name === 'string' ? svc.container_name : ''; + return `${name} ${cn}`.toLowerCase().replace(/[_.]/g, '-').includes('socket-proxy'); +} + +/** + * Is this service a dedicated Docker socket proxy? A known image is an artifact + * identity, so it stands alone. A service NAME is author-controlled free text, so + * it only counts with an observable fact (a read-only socket, or >=1 enabled API + * flag). With neither name nor image hint, require both. Prefer false negatives: + * a miss keeps the high direct-mount finding. + */ +function isSocketProxyService(name: string, svc: Record): boolean { + if (!mountsDockerSocket(svc)) return false; + if (hasSocketProxyImage(svc)) return true; + const readOnly = socketBinds(svc).some((b) => b.readonly); + const apiKeyCount = enabledProxyApiFlags(svc).length; + if (hasSocketProxyNameHint(name, svc)) return readOnly || apiKeyCount >= 1; + return readOnly && apiKeyCount >= 2; +} + +/** The service NAMES that are classified socket proxies in this compose. */ +function socketProxyNames(p: ParsedCompose): string[] { + return Object.entries(services(p)) + .filter(([name, svc]) => isSocketProxyService(name, svc)) + .map(([name]) => name); +} + +/** Network keys a service is attached to (array or map form); empty = implicit default. */ +function serviceNetworkKeys(svc: Record): string[] { + const nets = svc.networks; + if (Array.isArray(nets)) return nets.filter((n): n is string => typeof n === 'string'); + const rec = asRecord(nets); + return rec ? Object.keys(rec) : []; +} + +/** + * True unless EVERY network the proxy joins is declared `internal: true` in the + * top-level networks: block. No membership -> implicit default network (not + * internal). A network the file does not describe cannot be shown internal. + * NOTE: reads the LITERAL source networks: block, not a merged/extends model. + */ +function proxyAttachesNonInternalNetwork(p: ParsedCompose, svc: Record): boolean { + const keys = serviceNetworkKeys(svc); + if (keys.length === 0) return true; + const topNets = asRecord(p.doc?.networks) ?? {}; + return keys.some((k) => asRecord(topNets[k])?.internal !== true); +} + +/** tcp:// hosts a service points its Docker client at (DOCKER_HOST env + tcp in command). */ +function dockerEndpointHosts(svc: Record): string[] { + const hosts: string[] = []; + for (const e of envEntries(svc)) { + if (e.key.toUpperCase() === 'DOCKER_HOST' && e.value) { + const m = /tcp:\/\/([^:/\s]+)/i.exec(e.value); + if (m) hosts.push(m[1].toLowerCase()); + } + } + const cmd = svc.command; + const cmdStr = Array.isArray(cmd) ? cmd.join(' ') : typeof cmd === 'string' ? cmd : ''; + for (const m of cmdStr.matchAll(/tcp:\/\/([^:/\s]+)/gi)) hosts.push(m[1].toLowerCase()); + return hosts; +} + +/** Names a dependent could use to reach this proxy on a shared network. */ +function proxyReachableNames(name: string, svc: Record): string[] { + const out = [name.toLowerCase()]; + if (typeof svc.container_name === 'string') out.push(svc.container_name.toLowerCase()); + // network aliases (map form: networks: { net: { aliases: [...] } }) + const rec = asRecord(svc.networks); + if (rec) { + for (const membership of Object.values(rec)) { + const aliases = asRecord(membership)?.aliases; + if (Array.isArray(aliases)) for (const a of aliases) if (typeof a === 'string') out.push(a.toLowerCase()); + } + } + return out; +} + // --- rule catalog --------------------------------------------------------------- export const RULES: RuleDefinition[] = [ @@ -476,35 +623,200 @@ export const RULES: RuleDefinition[] = [ defaultSeverity: 'warn', // graded: the check upgrades rw to error itself check(p) { const out: RuleFinding[] = []; + const proxies = socketProxyNames(p); + // When the stack already runs a proxy, point a direct-mount finding at it + // instead of telling the user to adopt something they already have. + const proxyHint = + proxies.length > 0 + ? `This stack already runs a socket proxy (${proxies.map((n) => `"${n}"`).join(', ')}); route this service through it instead of mounting docker.sock directly.` + : 'Read-write docker.sock = root on the host. Use a scoped socket proxy.'; for (const [name, svc] of Object.entries(services(p))) { + // A classified socket proxy legitimately mounts the socket - the + // docker-socket-proxy* rules cover it, so skip it here. + if (isSocketProxyService(name, svc)) continue; + // Dockhand itself REQUIRES the socket (rw) to manage Docker, so a stack + // that deploys Dockhand is not a footgun - info note, not error. + const image = typeof svc.image === 'string' ? svc.image : ''; + const isDockhand = DOCKHAND_IMAGE.test(image); const vols = Array.isArray(svc.volumes) ? svc.volumes : []; for (let i = 0; i < vols.length; i++) { const v = vols[i]; const src = typeof v === 'string' ? v : asRecord(v)?.source; const raw = typeof v === 'string' ? v : ''; - if (typeof src === 'string' && src.includes('/var/run/docker.sock')) { + if (typeof src === 'string' && src.includes('docker.sock')) { // Read-only if the short-form option field contains `ro` (ro / ro,z / // z,ro), or the long form sets read_only. Only 3+ segments have opts. const parts = raw.split(':'); const readonly = (parts.length >= 3 && optsAreReadonly(parts[parts.length - 1])) || asRecord(v)?.read_only === true; - out.push({ - ruleId: 'DOCKER_SOCKET_MOUNT', - severity: readonly ? 'warn' : 'error', - service: name, - message: `"${name}" mounts the Docker socket${readonly ? ' (read-only)' : ' read-write (full daemon control)'}`, - hint: readonly - ? 'Read-only still exposes the daemon; consider a socket proxy.' - : 'Read-write docker.sock = root on the host. Use a scoped socket proxy.', - line: p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name]) - }); + const line = p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name]); + if (isDockhand) { + out.push({ + ruleId: 'DOCKER_SOCKET_MOUNT', + severity: 'info', + service: name, + message: `"${name}" is Dockhand and mounts the Docker socket - this is a required configuration, not a misconfiguration`, + hint: 'Dockhand needs the socket to manage Docker. To harden it, put a scoped socket proxy in front.', + line + }); + } else { + out.push({ + ruleId: 'DOCKER_SOCKET_MOUNT', + severity: readonly ? 'warn' : 'error', + service: name, + message: `"${name}" mounts the Docker socket${readonly ? ' (read-only)' : ' read-write (full daemon control)'}`, + hint: readonly ? 'Read-only still exposes the daemon; consider a socket proxy.' : proxyHint, + line + }); + } } } } return out; } }, + { + id: 'DOCKER_SOCKET_PROXY', + description: 'A Docker socket proxy sidecar is present (intentional)', + group: 'security', + defaultSeverity: 'info', + check(p) { + const out: RuleFinding[] = []; + for (const name of socketProxyNames(p)) { + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY', + service: name, + message: `"${name}" is a Docker socket proxy - it mounts the socket on purpose to expose a scoped API to other services`, + hint: 'Keep it on an internal network, do not publish its port, and enable only the API groups dependents need.', + line: p.lineOf(['services', name]) + }); + } + return out; + } + }, + { + id: 'DOCKER_SOCKET_PROXY_WRITABLE', + description: 'A Docker socket proxy mounts the socket read-write', + group: 'security', + defaultSeverity: 'error', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + if (!isSocketProxyService(name, svc)) continue; + if (!socketBinds(svc).some((b) => !b.readonly)) continue; + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY_WRITABLE', + service: name, + message: `Socket proxy "${name}" mounts docker.sock read-write - a compromise of the proxy then grants full control of the host`, + hint: 'Append `:ro` to the socket bind and let the API-group flags do the restricting.', + line: p.lineOf(['services', name, 'volumes']) ?? p.lineOf(['services', name]) + }); + } + return out; + } + }, + { + id: 'DOCKER_SOCKET_PROXY_PUBLISHED', + description: 'A Docker socket proxy publishes a host port', + group: 'security', + defaultSeverity: 'error', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + if (!isSocketProxyService(name, svc)) continue; + const hasPublished = ports(svc).some((e) => { + const parsed = parsePortEntry(e); + return parsed?.hostPort != null; + }); + if (!hasPublished) continue; + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY_PUBLISHED', + service: name, + message: `Socket proxy "${name}" publishes a port to the host, making the proxied Docker API reachable beyond the compose network`, + hint: 'Remove the host port mapping; let dependents reach the proxy only over an internal compose network.', + line: p.lineOf(['services', name, 'ports']) ?? p.lineOf(['services', name]) + }); + } + return out; + } + }, + { + id: 'DOCKER_SOCKET_PROXY_MUTATING', + description: 'A Docker socket proxy allows mutating (POST/DELETE) API access', + group: 'security', + defaultSeverity: 'warn', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + if (!isSocketProxyService(name, svc)) continue; + const mutating = enabledProxyApiFlags(svc).filter((f) => MUTATING_PROXY_API_FLAGS.has(f)); + if (mutating.length === 0) continue; + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY_MUTATING', + service: name, + message: `Socket proxy "${name}" enables ${mutating.join(' and ')} - dependents can perform write/delete operations through it`, + hint: 'Disable POST and DELETE unless a dependent genuinely needs mutating Docker API calls.', + line: p.lineOf(['services', name]) + }); + } + return out; + } + }, + { + id: 'DOCKER_SOCKET_PROXY_EXPOSURE', + description: 'A Docker socket proxy sits on a non-internal network', + group: 'security', + defaultSeverity: 'warn', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + if (!isSocketProxyService(name, svc)) continue; + if (!proxyAttachesNonInternalNetwork(p, svc)) continue; + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY_EXPOSURE', + service: name, + message: `Socket proxy "${name}" is on a network that is not marked internal: true, which widens who can reach the proxied Docker API`, + hint: 'Attach the proxy only to internal: true compose networks used by trusted dependents.', + line: p.lineOf(['services', name, 'networks']) ?? p.lineOf(['services', name]) + }); + } + return out; + } + }, + { + id: 'DOCKER_SOCKET_PROXY_CLIENT', + description: 'A service reaches Docker through a socket proxy instead of mounting the socket', + group: 'security', + defaultSeverity: 'info', + check(p) { + const proxyEntries = Object.entries(services(p)).filter(([n, s]) => isSocketProxyService(n, s)); + if (proxyEntries.length === 0) return []; + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + if (mountsDockerSocket(svc)) continue; // it mounts directly - not a proxy client + const hosts = new Set(dockerEndpointHosts(svc)); + if (hosts.size === 0) continue; + const svcNets = serviceNetworkKeys(svc); + const isClient = proxyEntries.some(([pn, ps]) => { + const reach = proxyReachableNames(pn, ps); + if (!reach.some((r) => hosts.has(r))) return false; + const proxyNets = serviceNetworkKeys(ps); + // Empty membership on either side = implicit default; match only if BOTH default. + if (svcNets.length === 0 || proxyNets.length === 0) return svcNets.length === 0 && proxyNets.length === 0; + return svcNets.some((n) => proxyNets.includes(n)); + }); + if (!isClient) continue; + out.push({ + ruleId: 'DOCKER_SOCKET_PROXY_CLIENT', + service: name, + message: `"${name}" reaches Docker through a socket proxy instead of mounting docker.sock directly - the safer pattern`, + line: p.lineOf(['services', name]) + }); + } + return out; + } + }, { id: 'OBSOLETE_VERSION_KEY', description: 'The top-level version: key is obsolete', @@ -728,5 +1040,95 @@ export const RULES: RuleDefinition[] = [ } return out; } + }, + { + id: 'SENSITIVE_SERVICE_BROAD_EXPOSURE', + description: 'An admin/management UI publishes a port on all interfaces', + group: 'security', + defaultSeverity: 'error', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + const image = typeof svc.image === 'string' ? svc.image : ''; + if (!ADMIN_PANEL_IMAGE.test(image)) continue; + for (let i = 0; i < ports(svc).length; i++) { + const parsed = parsePortEntry(ports(svc)[i]); + if (parsed?.hostPort && (!parsed.hostIp || parsed.hostIp === '0.0.0.0')) { + out.push({ + ruleId: 'SENSITIVE_SERVICE_BROAD_EXPOSURE', + service: name, + message: `Admin UI "${name}" publishes port ${parsed.hostPort} on all interfaces - reachable from the whole network`, + hint: 'Bind to 127.0.0.1 and reach it through a reverse proxy with auth, or drop the host mapping.', + line: p.lineOf(['services', name, 'ports', i]) ?? p.lineOf(['services', name]) + }); + } + } + } + return out; + } + }, + { + id: 'ANONYMOUS_VOLUME', + description: 'A service uses an anonymous volume (data has no stable name and is easy to lose)', + group: 'reliability', + defaultSeverity: 'info', + check(p) { + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + const vols = Array.isArray(svc.volumes) ? svc.volumes : []; + for (let i = 0; i < vols.length; i++) { + const v = vols[i]; + // Anonymous = a container path with no source. Short form: a single + // segment ("/data"), i.e. no ':' mapping. Long form: type volume with + // no `source`. A bind (./x, /x:/y) or named volume (data:/x) has a + // source and is NOT anonymous. + let anon = false; + if (typeof v === 'string') { + anon = !v.includes(':'); + } else { + const o = asRecord(v); + if (o && (o.type === 'volume' || o.type === undefined) && !o.source) anon = true; + } + if (anon) { + out.push({ + ruleId: 'ANONYMOUS_VOLUME', + service: name, + message: `"${name}" uses an anonymous volume - its data has no stable name and is orphaned when the container is recreated`, + hint: 'Give it a named volume (name:/path) or a bind mount so the data is findable and portable.', + line: p.lineOf(['services', name, 'volumes', i]) ?? p.lineOf(['services', name]) + }); + } + } + } + return out; + } + }, + { + id: 'SWARM_ONLY_DEPLOY_KEYS', + description: 'deploy.* keys that standalone docker compose silently ignores', + group: 'reliability', + defaultSeverity: 'warn', + check(p) { + // Keys under `deploy:` that ONLY take effect in Swarm mode; `docker + // compose up` ignores them without warning. Deliberately conservative: + // deploy.replicas, deploy.mode and deploy.resources ARE partially honored + // by Compose v2, so they are NOT flagged. + const SWARM_ONLY = ['placement', 'update_config', 'rollback_config', 'endpoint_mode']; + const out: RuleFinding[] = []; + for (const [name, svc] of Object.entries(services(p))) { + const deploy = asRecord(svc.deploy); + if (!deploy) continue; + const present = SWARM_ONLY.filter((k) => k in deploy); + if (present.length === 0) continue; + out.push({ + ruleId: 'SWARM_ONLY_DEPLOY_KEYS', + service: name, + message: `"${name}" sets deploy.${present.join(', deploy.')} - standalone docker compose ignores these (they only apply in Swarm mode)`, + hint: 'Use top-level equivalents (e.g. cpus:/mem_limit:, or scale via the CLI) unless you deploy to Swarm.', + line: p.lineOf(['services', name, 'deploy']) ?? p.lineOf(['services', name]) + }); + } + return out; + } } ]; diff --git a/tests/compose-validate/rules.test.ts b/tests/compose-validate/rules.test.ts index 275ef3c..b469615 100644 --- a/tests/compose-validate/rules.test.ts +++ b/tests/compose-validate/rules.test.ts @@ -441,8 +441,182 @@ describe('review regressions (found by the final audit)', () => { const f = find(`services:\n a:\n image: x\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:z,ro\n`, 'DOCKER_SOCKET_MOUNT'); expect(f?.severity).toBe('warn'); }); + test('DOCKER_SOCKET_MOUNT: catches the /run/docker.sock path (no /var)', () => { + const f = find(`services:\n a:\n image: x\n volumes:\n - /run/docker.sock:/run/docker.sock\n`, 'DOCKER_SOCKET_MOUNT'); + expect(f?.severity).toBe('error'); + }); + test('DOCKER_SOCKET_MOUNT: Dockhand itself gets an info note, not error', () => { + for (const img of ['fnsys/dockhand', 'finsys/dockhand:v1.0.44', 'registry.bor6.pl/dockhand:abc123']) { + const f = find(`services:\n dh:\n image: ${img}\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n`, 'DOCKER_SOCKET_MOUNT'); + expect(f?.severity).toBe('info'); + expect(f?.message).toContain('required'); + } + }); + test('DOCKER_SOCKET_MOUNT: a non-Dockhand image with rw socket stays error', () => { + const f = find(`services:\n watchtower:\n image: containrrr/watchtower\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n`, 'DOCKER_SOCKET_MOUNT'); + expect(f?.severity).toBe('error'); + }); +}); + +describe('Docker socket proxy classifier (#1791)', () => { + // A known proxy image is classified on its own, ro socket + limited flags. + const proxyStack = (extra = '') => + `services:\n` + + ` socket-proxy:\n` + + ` image: tecnativa/docker-socket-proxy\n` + + ` volumes:\n` + + ` - /var/run/docker.sock:/var/run/docker.sock:ro\n` + + ` environment:\n` + + ` - CONTAINERS=1\n` + + extra; + + test('a recognised proxy image => DOCKER_SOCKET_PROXY info, NOT a socket-mount error', () => { + const r = runValidate(proxyStack()); + const proxy = r.findings.find((f) => f.ruleId === 'DOCKER_SOCKET_PROXY'); + expect(proxy?.severity).toBe('info'); + // the generic direct-mount rule must NOT fire for the proxy + expect(r.findings.some((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')).toBe(false); + }); + + test('detection ladder: proxy-shaped NAME needs ro-socket OR a flag', () => { + // name hint + ro socket, no flags => classified + const nameRo = `services:\n my-socket-proxy:\n image: someorg/thing\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n`; + expect(ids(nameRo)).toContain('DOCKER_SOCKET_PROXY'); + // no name hint, rw socket, one flag => NOT enough (needs ro+2 flags) => stays a direct mount + const weak = `services:\n thing:\n image: someorg/thing\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n environment:\n - CONTAINERS=1\n`; + expect(ids(weak)).not.toContain('DOCKER_SOCKET_PROXY'); + expect(ids(weak)).toContain('DOCKER_SOCKET_MOUNT'); + }); + + test('detection ladder: no name/image hint needs ro socket AND >=2 flags', () => { + const twoFlags = `services:\n x:\n image: someorg/thing\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n environment:\n - CONTAINERS=1\n - IMAGES=1\n`; + expect(ids(twoFlags)).toContain('DOCKER_SOCKET_PROXY'); + }); + + test('DOCKER_SOCKET_PROXY_WRITABLE: proxy with rw socket is an error', () => { + const rw = `services:\n socket-proxy:\n image: tecnativa/docker-socket-proxy\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n environment:\n - CONTAINERS=1\n`; + const f = find(rw, 'DOCKER_SOCKET_PROXY_WRITABLE'); + expect(f?.severity).toBe('error'); + }); + + test('DOCKER_SOCKET_PROXY_PUBLISHED: proxy publishing a host port is an error', () => { + const f = find(proxyStack(` ports:\n - "2375:2375"\n`), 'DOCKER_SOCKET_PROXY_PUBLISHED'); + expect(f?.severity).toBe('error'); + }); + + test('DOCKER_SOCKET_PROXY_MUTATING: POST/DELETE=1 is a warning', () => { + const f = find(proxyStack(` - POST=1\n - DELETE=1\n`), 'DOCKER_SOCKET_PROXY_MUTATING'); + expect(f?.severity).toBe('warn'); + expect(f?.message).toContain('POST'); + expect(f?.message).toContain('DELETE'); + }); + + test('DOCKER_SOCKET_PROXY_EXPOSURE: proxy on a non-internal network warns', () => { + const nonInternal = + `services:\n socket-proxy:\n image: tecnativa/docker-socket-proxy\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n environment:\n - CONTAINERS=1\n networks:\n - frontend\n` + + `networks:\n frontend: {}\n`; + expect(ids(nonInternal)).toContain('DOCKER_SOCKET_PROXY_EXPOSURE'); + // internal: true silences it + const internal = + `services:\n socket-proxy:\n image: tecnativa/docker-socket-proxy\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n environment:\n - CONTAINERS=1\n networks:\n - dockerapi\n` + + `networks:\n dockerapi:\n internal: true\n`; + expect(ids(internal)).not.toContain('DOCKER_SOCKET_PROXY_EXPOSURE'); + }); + + test('DOCKER_SOCKET_PROXY_CLIENT: a service using the proxy via DOCKER_HOST on a shared net', () => { + const src = + `services:\n` + + ` socket-proxy:\n image: tecnativa/docker-socket-proxy\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n environment:\n - CONTAINERS=1\n networks:\n - dockerapi\n` + + ` app:\n image: someorg/app\n environment:\n - DOCKER_HOST=tcp://socket-proxy:2375\n networks:\n - dockerapi\n` + + `networks:\n dockerapi:\n internal: true\n`; + const f = find(src, 'DOCKER_SOCKET_PROXY_CLIENT'); + expect(f?.service).toBe('app'); + expect(f?.severity).toBe('info'); + }); + + test('a proxy in the stack retargets a direct-mounter remediation', () => { + const src = + `services:\n` + + ` socket-proxy:\n image: tecnativa/docker-socket-proxy\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro\n environment:\n - CONTAINERS=1\n` + + ` bad:\n image: someorg/bad\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n`; + const f = find(src, 'DOCKER_SOCKET_MOUNT'); + expect(f?.service).toBe('bad'); + expect(f?.hint).toContain('already runs a socket proxy'); + }); test('WRITABLE_ROOT_MOUNT: rw with a label (:z only) stays error', () => { const f = find(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:z\n`, 'WRITABLE_ROOT_MOUNT'); expect(f?.severity).toBe('error'); }); }); + +describe('SENSITIVE_SERVICE_BROAD_EXPOSURE', () => { + test('an admin panel published on 0.0.0.0 is an error', () => { + const f = find(`services:\n ui:\n image: adminer\n ports:\n - "8080:8080"\n`, 'SENSITIVE_SERVICE_BROAD_EXPOSURE'); + expect(f?.severity).toBe('error'); + expect(f?.line).toBe(5); // the ports entry + }); + test('portainer and pgadmin variants are recognised', () => { + expect(ids(`services:\n p:\n image: portainer/portainer-ce:latest\n ports:\n - "9000:9000"\n`)) + .toContain('SENSITIVE_SERVICE_BROAD_EXPOSURE'); + expect(ids(`services:\n p:\n image: dpage/pgadmin4\n ports:\n - "80:80"\n`)) + .toContain('SENSITIVE_SERVICE_BROAD_EXPOSURE'); + }); + test('bound to 127.0.0.1 is fine', () => { + expect(ids(`services:\n ui:\n image: adminer\n ports:\n - "127.0.0.1:8080:8080"\n`)) + .not.toContain('SENSITIVE_SERVICE_BROAD_EXPOSURE'); + }); + test('a plain database does NOT trip this rule (that is DB_PORT_ON_ALL_INTERFACES)', () => { + const found = ids(`services:\n db:\n image: postgres:16\n ports:\n - "5432:5432"\n`); + expect(found).not.toContain('SENSITIVE_SERVICE_BROAD_EXPOSURE'); + expect(found).toContain('DB_PORT_ON_ALL_INTERFACES'); + }); + test('admin UI with no published port is not flagged', () => { + expect(ids(`services:\n ui:\n image: adminer\n`)).not.toContain('SENSITIVE_SERVICE_BROAD_EXPOSURE'); + }); +}); + +describe('ANONYMOUS_VOLUME', () => { + test('a short-form container path with no source is anonymous (info)', () => { + const f = find(`services:\n a:\n image: x\n volumes:\n - /var/lib/data\n`, 'ANONYMOUS_VOLUME'); + expect(f?.severity).toBe('info'); + expect(f?.line).toBe(5); + }); + test('long-form volume with no source is anonymous', () => { + const src = `services:\n a:\n image: x\n volumes:\n - type: volume\n target: /data\n`; + expect(ids(src)).toContain('ANONYMOUS_VOLUME'); + }); + test('a named volume is NOT anonymous', () => { + expect(ids(`services:\n a:\n image: x\n volumes:\n - data:/var/lib/data\n`)) + .not.toContain('ANONYMOUS_VOLUME'); + }); + test('a bind mount is NOT anonymous', () => { + expect(ids(`services:\n a:\n image: x\n volumes:\n - ./data:/var/lib/data\n`)) + .not.toContain('ANONYMOUS_VOLUME'); + expect(ids(`services:\n a:\n image: x\n volumes:\n - type: bind\n source: ./data\n target: /data\n`)) + .not.toContain('ANONYMOUS_VOLUME'); + }); + test('a tmpfs long-form is NOT anonymous (no persistent data)', () => { + expect(ids(`services:\n a:\n image: x\n volumes:\n - type: tmpfs\n target: /tmp\n`)) + .not.toContain('ANONYMOUS_VOLUME'); + }); +}); + +describe('SWARM_ONLY_DEPLOY_KEYS', () => { + test('deploy.placement is flagged (warn)', () => { + const f = find(`services:\n a:\n image: x\n deploy:\n placement:\n constraints: [node.role == manager]\n`, 'SWARM_ONLY_DEPLOY_KEYS'); + expect(f?.severity).toBe('warn'); + expect(f?.line).toBe(4); // the deploy: line + }); + test('multiple swarm-only keys are listed in one finding', () => { + const f = find(`services:\n a:\n image: x\n deploy:\n update_config:\n parallelism: 2\n rollback_config:\n parallelism: 1\n`, 'SWARM_ONLY_DEPLOY_KEYS'); + expect(f?.message).toContain('update_config'); + expect(f?.message).toContain('rollback_config'); + }); + test('deploy.replicas / resources are NOT flagged (Compose honors them)', () => { + expect(ids(`services:\n a:\n image: x\n deploy:\n replicas: 3\n resources:\n limits:\n cpus: "0.5"\n`)) + .not.toContain('SWARM_ONLY_DEPLOY_KEYS'); + }); + test('no deploy block, no finding', () => { + expect(ids(`services:\n a:\n image: x\n restart: always\n`)).not.toContain('SWARM_ONLY_DEPLOY_KEYS'); + }); +});