compose validate: recognize socket proxies and add exposure/volume/deploy rules
This commit is contained in:
2 files changed
+587
-11
No files matched your search
@@ -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<string, unknown>): { 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<string, unknown>): boolean {
|
||||
return socketBinds(svc).length > 0;
|
||||
}
|
||||
|
||||
/** The enabled proxy API-group flag NAMES (value exactly `1`). */
|
||||
function enabledProxyApiFlags(svc: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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, unknown>): 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<string, unknown>): 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, unknown>): 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, unknown>): 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;
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user