mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
78475d96ef
* fix(compose-doctor): resolve effective healthcheck coverage Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active. * fix(compose-doctor): fix healthcheck project label and empty compose HC Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active. * fix(compose-doctor): treat inherited healthcheck as All Clear note Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
560 lines
31 KiB
TypeScript
560 lines
31 KiB
TypeScript
/**
|
|
* The preflight rule registry. Each rule is a pure function over a
|
|
* PreflightContext; these tests assert each fires on its positive case, stays
|
|
* silent otherwise, and carries the right severity. They also pin the port
|
|
* conflict semantics (protocol, interface overlap, same-stack, ranges) and keep
|
|
* the registry aligned with the documented rule set.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { runRules, RULE_IDS } from '../services/preflight/rules';
|
|
import type { EffService, EffectiveModel } from '../services/preflight/effectiveModel';
|
|
import type { PreflightContext, PreflightFinding } from '../services/preflight/types';
|
|
|
|
function svc(over: Partial<EffService> = {}): EffService {
|
|
const hasHealthcheck = over.hasHealthcheck ?? true;
|
|
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
|
|
return {
|
|
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
|
|
privileged: false, restart: 'unless-stopped', envKeys: [],
|
|
networks: [], extraHosts: [], labelKeys: [],
|
|
...over,
|
|
hasHealthcheck,
|
|
composeHealthcheck,
|
|
};
|
|
}
|
|
|
|
function model(services: EffService[], over: Partial<EffectiveModel> = {}): EffectiveModel {
|
|
return { projectName: 'proj', services, networks: {}, volumes: {}, ...over };
|
|
}
|
|
|
|
function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
|
const m = over.model !== undefined ? over.model : model([]);
|
|
return {
|
|
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
|
|
literalDollarWarnings: [],
|
|
missingEnvFiles: [],
|
|
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
|
|
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
|
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
|
|
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
|
|
exposureAvailable: true,
|
|
isSelfStack: false,
|
|
healthchecks: {},
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const ids = (findings: PreflightFinding[], ruleId: string) => findings.filter(f => f.ruleId === ruleId);
|
|
|
|
describe('render-failed', () => {
|
|
it('fires only when the model is unrenderable', () => {
|
|
const f = runRules(ctx({ renderable: false, model: null, renderError: 'boom' }));
|
|
expect(ids(f, 'render-failed')).toHaveLength(1);
|
|
expect(ids(f, 'render-failed')[0].severity).toBe('blocker');
|
|
expect(ids(f, 'render-failed')[0].message).toContain('boom');
|
|
});
|
|
it('stays silent and runs model rules when renderable', () => {
|
|
expect(ids(runRules(ctx({ model: model([svc()]) })), 'render-failed')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('env-unset', () => {
|
|
it('emits one high finding per unset variable name', () => {
|
|
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO', 'BAR'] })), 'env-unset');
|
|
expect(f).toHaveLength(2);
|
|
expect(f[0].severity).toBe('high');
|
|
expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']);
|
|
});
|
|
it('mentions literal-dollar escapes in remediation', () => {
|
|
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO'] })), 'env-unset');
|
|
expect(f[0].remediation).toContain('$$');
|
|
expect(f[0].remediation).toContain('single-quote');
|
|
});
|
|
});
|
|
|
|
describe('env-literal-dollar', () => {
|
|
it('emits a safe finding for likely-secret literal dollar warnings', () => {
|
|
const f = ids(runRules(ctx({
|
|
literalDollarWarnings: [{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true, service: 'demo' }],
|
|
})), 'env-literal-dollar');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].severity).toBe('high');
|
|
expect(f[0].title).toContain('likely secret');
|
|
expect(f[0].sourcePath).toBe('EXAMPLE_AUTH_HASH');
|
|
expect(f[0].service).toBe('demo');
|
|
expect(f[0].title).not.toContain('E6SDEbshpc');
|
|
expect(f[0].remediation).toContain('$$');
|
|
});
|
|
it('omits fragment names from generic literal-dollar findings', () => {
|
|
const f = ids(runRules(ctx({
|
|
literalDollarWarnings: [{ likelySecret: false }],
|
|
})), 'env-literal-dollar');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].sourcePath).toBeUndefined();
|
|
expect(f[0].title).toContain('environment value');
|
|
});
|
|
});
|
|
|
|
describe('env-file-missing', () => {
|
|
it('emits one high finding per missing required env file', () => {
|
|
const f = ids(runRules(ctx({ missingEnvFiles: [{ rawPath: './db.env', services: ['db'] }] })), 'env-file-missing');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].severity).toBe('high');
|
|
expect(f[0].sourcePath).toBe('./db.env');
|
|
expect(f[0].service).toBe('db');
|
|
});
|
|
it('stays silent when there are no missing env files (optional/unverifiable are pre-filtered)', () => {
|
|
expect(ids(runRules(ctx({ missingEnvFiles: [] })), 'env-file-missing')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('port-conflict-node', () => {
|
|
const withPort = (proto = 'tcp', hostIp = '') => model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: proto }] })]);
|
|
|
|
it('blocks a port held by a different stack', () => {
|
|
const f = runRules(ctx({ model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
|
expect(ids(f, 'port-conflict-node')[0].severity).toBe('blocker');
|
|
});
|
|
it('ignores the same stack reusing its own port', () => {
|
|
const f = runRules(ctx({ stackName: 'proj', model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'proj' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
|
|
});
|
|
it('does not conflict TCP with UDP on the same number', () => {
|
|
const f = runRules(ctx({ model: withPort('tcp'), nodePorts: [{ publishedPort: 8080, protocol: 'udp', ip: '', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
|
|
});
|
|
it('treats a loopback bind as overlapping an all-interfaces bind', () => {
|
|
const f = runRules(ctx({ model: withPort('tcp', '127.0.0.1'), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
|
});
|
|
it('catches a port inside a published range', () => {
|
|
const m = model([svc({ ports: [{ startPort: 9000, endPort: 9002, hostIp: '', protocol: 'tcp' }] })]);
|
|
const f = runRules(ctx({ model: m, nodePorts: [{ publishedPort: 9001, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('port-conflict-internal', () => {
|
|
it('blocks two services publishing the same host port', () => {
|
|
const m = model([
|
|
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
|
|
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
|
|
]);
|
|
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(1);
|
|
});
|
|
it('allows the same number on different interfaces', () => {
|
|
const m = model([
|
|
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] }),
|
|
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '192.168.1.5', protocol: 'tcp' }] }),
|
|
]);
|
|
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('port-exposed-all-interfaces', () => {
|
|
it('flags an all-interfaces bind but not a loopback bind', () => {
|
|
const open = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] })]);
|
|
const local = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] })]);
|
|
expect(ids(runRules(ctx({ model: open })), 'port-exposed-all-interfaces')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: local })), 'port-exposed-all-interfaces')).toHaveLength(0);
|
|
});
|
|
it('treats :: (IPv6 all-interfaces) as exposed and overlapping', () => {
|
|
const v6 = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '::', protocol: 'tcp' }] })]);
|
|
expect(ids(runRules(ctx({ model: v6 })), 'port-exposed-all-interfaces')).toHaveLength(1);
|
|
const f = runRules(ctx({ model: v6, nodePorts: [{ publishedPort: 80, protocol: 'tcp', ip: '127.0.0.1', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('bind-path-missing / bind-path-permission', () => {
|
|
it('flags a missing within-base bind as high', () => {
|
|
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: false, ownerUid: null }] }));
|
|
expect(ids(f, 'bind-path-missing')).toHaveLength(1);
|
|
expect(ids(f, 'bind-path-missing')[0].severity).toBe('high');
|
|
});
|
|
it('does not assert an absolute (outside-base) bind as missing', () => {
|
|
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/mnt/media', target: '/media', withinBase: false, exists: false, ownerUid: null }] }));
|
|
expect(ids(f, 'bind-path-missing')).toHaveLength(0);
|
|
});
|
|
it('warns on a root-owned within-base bind when the service drops privileges', () => {
|
|
const m = model([svc({ envKeys: ['PUID'] })]);
|
|
const bind = { service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
|
|
expect(ids(runRules(ctx({ model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(1);
|
|
});
|
|
it('skips the ownership heuristic on Windows', () => {
|
|
const m = model([svc({ envKeys: ['PUID'] })]);
|
|
const bind = { service: 'web', source: 'C:/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
|
|
expect(ids(runRules(ctx({ platform: 'win32', model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('uid-gid-risk', () => {
|
|
it('fires only for unverifiable (outside-base) binds', () => {
|
|
const m = model([svc({ name: 'web', envKeys: ['PUID'] })]);
|
|
const outside = [{ service: 'web', source: '/mnt/x', target: '/x', withinBase: false, exists: false, ownerUid: null }];
|
|
const inside = [{ service: 'web', source: '/base/proj/x', target: '/x', withinBase: true, exists: true, ownerUid: 1000 }];
|
|
expect(ids(runRules(ctx({ model: m, bindChecks: outside })), 'uid-gid-risk')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: m, bindChecks: inside })), 'uid-gid-risk')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('hygiene rules', () => {
|
|
it('flags a moving image tag but not a pinned one', () => {
|
|
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:latest' })]) })), 'image-latest')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx' })]) })), 'image-latest')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:1.27' })]) })), 'image-latest')).toHaveLength(0);
|
|
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx@sha256:abc' })]) })), 'image-latest')).toHaveLength(0);
|
|
});
|
|
it('flags a missing restart policy and healthcheck', () => {
|
|
const bare = model([svc({ restart: undefined, hasHealthcheck: false })]);
|
|
const restartFindings = ids(runRules(ctx({ model: bare })), 'no-restart-policy');
|
|
expect(restartFindings).toHaveLength(1);
|
|
expect(restartFindings[0].remediation).toMatch(/one-shot|init jobs/i);
|
|
expect(restartFindings[0].remediation).toMatch(/restart: "no"/);
|
|
expect(restartFindings[0].remediation).toMatch(/unless-stopped/);
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'absent', origin: 'local-image', consistentReplicas: null } },
|
|
})), 'no-healthcheck')).toHaveLength(1);
|
|
const withDeployRestart = model([svc({ restart: undefined, deploy: { restart_policy: { condition: 'any' } }})]);
|
|
expect(ids(runRules(ctx({ model: withDeployRestart })), 'no-restart-policy')).toHaveLength(0);
|
|
});
|
|
|
|
it('emits the healthcheck evidence rule family', () => {
|
|
const bare = model([svc({ hasHealthcheck: false })]);
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'explicitly-disabled', origin: 'compose', consistentReplicas: null } },
|
|
})), 'healthcheck-disabled')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
|
|
})), 'healthcheck-inherited')[0]).toMatchObject({
|
|
severity: 'info',
|
|
title: 'Healthcheck inherited from image',
|
|
});
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'local-image-inherited', origin: 'local-image', consistentReplicas: null } },
|
|
})), 'healthcheck-inherited')[0].remediation).toMatch(/Optionally declare/);
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'unverifiable', origin: 'none', consistentReplicas: null } },
|
|
})), 'healthcheck-unverifiable')[0].severity).toBe('info');
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'inconsistent-replicas', origin: 'runtime', consistentReplicas: false } },
|
|
})), 'healthcheck-inconsistent')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'compose-declared', origin: 'compose', consistentReplicas: null } },
|
|
})), 'no-healthcheck')).toHaveLength(0);
|
|
});
|
|
|
|
it('never embeds healthcheck Test command text in findings', () => {
|
|
const bare = model([svc({ hasHealthcheck: false })]);
|
|
const findings = runRules(ctx({
|
|
model: bare,
|
|
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
|
|
}));
|
|
const blob = findings.map(f => `${f.title}\n${f.message}\n${f.remediation ?? ''}`).join('\n');
|
|
expect(blob).not.toMatch(/\bCMD\b/);
|
|
expect(blob).not.toMatch(/CMD-SHELL/);
|
|
expect(blob).not.toContain('secret-token');
|
|
});
|
|
it('flags swarm-only deploy fields but not honored ones', () => {
|
|
expect(ids(runRules(ctx({ model: model([svc({ deploy: { placement: {} }})]) })), 'deploy-swarm-only')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: model([svc({ deploy: { replicas: 3 }})]) })), 'deploy-swarm-only')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('network / volume rules', () => {
|
|
it('blocks a missing external network and volume', () => {
|
|
const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } }, volumes: { v: { name: 'data', external: true, internal: false } } });
|
|
const f = runRules(ctx({ model: m }));
|
|
expect(ids(f, 'external-network-missing')).toHaveLength(1);
|
|
expect(ids(f, 'external-volume-missing')).toHaveLength(1);
|
|
});
|
|
it('does not block an external resource that exists', () => {
|
|
const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } } });
|
|
const f = runRules(ctx({ model: m, existingNetworkNames: new Set(['shared']) }));
|
|
expect(ids(f, 'external-network-missing')).toHaveLength(0);
|
|
});
|
|
it('reports a new network/volume as info when absent on the node (implicit default names)', () => {
|
|
const m = model([svc()], {
|
|
networks: { backend: { name: 'proj_backend', external: false, internal: false } },
|
|
volumes: { data: { name: 'proj_data', external: false, internal: false } },
|
|
});
|
|
const f = runRules(ctx({ model: m }));
|
|
expect(ids(f, 'new-network')[0].severity).toBe('info');
|
|
expect(ids(f, 'new-network')[0].message).toContain('proj_backend');
|
|
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
|
|
});
|
|
it('uses explicit name equal to the compose key for new-network/new-volume (regression: #1581)', () => {
|
|
const m = model([svc()], {
|
|
projectName: 'proj',
|
|
networks: { backend: { name: 'backend', external: false, internal: false } },
|
|
volumes: { data: { name: 'data', external: false, internal: false } },
|
|
});
|
|
const f = runRules(ctx({ model: m }));
|
|
expect(ids(f, 'new-network')[0].message).toContain('"backend"');
|
|
expect(ids(f, 'new-network')[0].message).not.toContain('proj_backend');
|
|
expect(ids(f, 'new-volume')[0].message).toContain('"data"');
|
|
expect(ids(f, 'new-volume')[0].message).not.toContain('proj_data');
|
|
});
|
|
it('reports new-network with the true explicit name when project and key collide (regression: #1581)', () => {
|
|
const m = model([svc()], {
|
|
projectName: 'network',
|
|
networks: { tailscale: { name: 'tailscale', external: false, internal: false } },
|
|
});
|
|
const f = runRules(ctx({ model: m }));
|
|
const finding = ids(f, 'new-network')[0];
|
|
expect(finding.message).toContain('tailscale');
|
|
expect(finding.message).not.toContain('network_tailscale');
|
|
});
|
|
it('flags an anonymous volume as info and stays silent without one', () => {
|
|
const anon = model([svc({ storageMounts: [{ type: 'anonymous', target: '/data', readOnly: false }] })]);
|
|
const f = runRules(ctx({ model: anon }));
|
|
expect(ids(f, 'anonymous-volume')[0].severity).toBe('info');
|
|
expect(ids(f, 'anonymous-volume')[0].message).toContain('/data');
|
|
const named = model([svc({ storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }] })]);
|
|
expect(ids(runRules(ctx({ model: named })), 'anonymous-volume')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('container_name rules', () => {
|
|
it('blocks a duplicate container_name within the stack', () => {
|
|
const m = model([svc({ name: 'a', containerName: 'dup' }), svc({ name: 'b', containerName: 'dup' })]);
|
|
expect(ids(runRules(ctx({ model: m })), 'container-name-internal-dup')[0].severity).toBe('blocker');
|
|
});
|
|
it('blocks a container_name owned by a different stack', () => {
|
|
const m = model([svc({ containerName: 'taken' })]);
|
|
const f = runRules(ctx({ model: m, existingContainers: [{ name: 'taken', stack: 'other' }] }));
|
|
expect(ids(f, 'container-name-collision')[0].severity).toBe('blocker');
|
|
});
|
|
it('does not flag a container_name owned by the same stack', () => {
|
|
const m = model([svc({ containerName: 'mine' })]);
|
|
const f = runRules(ctx({ stackName: 'proj', model: m, existingContainers: [{ name: 'mine', stack: 'proj' }] }));
|
|
expect(ids(f, 'container-name-collision')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('effective-model-expanded', () => {
|
|
it('flags services present in the rendered model but not the source', () => {
|
|
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
|
|
const f = runRules(ctx({ model: m, sourceServiceNames: ['web'] }));
|
|
expect(ids(f, 'effective-model-expanded')).toHaveLength(1);
|
|
expect(ids(f, 'effective-model-expanded')[0].message).toContain('sidecar');
|
|
});
|
|
it('stays silent when source and effective services match', () => {
|
|
const m = model([svc({ name: 'web' })]);
|
|
expect(ids(runRules(ctx({ model: m, sourceServiceNames: ['web'] })), 'effective-model-expanded')).toHaveLength(0);
|
|
});
|
|
it('stays silent when the source could not be read (empty != zero services)', () => {
|
|
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
|
|
const f = runRules(ctx({ model: m, sourceServiceNames: [], sourceReadable: false }));
|
|
expect(ids(f, 'effective-model-expanded')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('exposure-intent rules', () => {
|
|
const withPort = (hostIp = '0.0.0.0', over: Partial<EffService> = {}) =>
|
|
model([svc({ name: 'web', ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: 'tcp' }], ...over })]);
|
|
|
|
it('flags a service classified internal that publishes a host port', () => {
|
|
const f = runRules(ctx({ model: withPort(), stackIntent: 'internal' }));
|
|
expect(ids(f, 'exposure-internal-published')).toHaveLength(1);
|
|
expect(ids(f, 'exposure-internal-published')[0].severity).toBe('high');
|
|
});
|
|
it('lets same-node tolerate a loopback bind but not a broad one', () => {
|
|
expect(ids(runRules(ctx({ model: withPort('127.0.0.1'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(0);
|
|
expect(ids(runRules(ctx({ model: withPort('0.0.0.0'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(1);
|
|
});
|
|
it('lets a per-service intent override the stack intent', () => {
|
|
// Stack is internal, but the service is reclassified public, so no finding.
|
|
const f = runRules(ctx({ model: withPort(), stackIntent: 'internal', serviceIntents: { web: 'public' } }));
|
|
expect(ids(f, 'exposure-internal-published')).toHaveLength(0);
|
|
});
|
|
it('same-node lists only the broad port when a service binds both loopback and broad', () => {
|
|
const m = model([svc({ name: 'web', ports: [
|
|
{ startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' },
|
|
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
|
] })]);
|
|
const f = ids(runRules(ctx({ model: m, stackIntent: 'same-node' })), 'exposure-internal-published');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].message).toContain('8080');
|
|
expect(f[0].message).not.toContain('9000');
|
|
});
|
|
it('warns when a port-publishing stack has no exposure intent', () => {
|
|
expect(ids(runRules(ctx({ model: withPort(), stackIntent: null })), 'exposure-unclassified')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'unknown' })), 'exposure-unclassified')).toHaveLength(1);
|
|
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'lan' })), 'exposure-unclassified')).toHaveLength(0);
|
|
});
|
|
it('lets a service-level intent suppress the unclassified warning even when the stack is unset', () => {
|
|
// web is the only publishing service; classifying it removes the gap.
|
|
expect(ids(runRules(ctx({ model: withPort(), stackIntent: null, serviceIntents: { web: 'public' } })), 'exposure-unclassified')).toHaveLength(0);
|
|
});
|
|
it('still warns when a publishing service is explicitly unknown over a classified stack', () => {
|
|
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'public', serviceIntents: { web: 'unknown' } })), 'exposure-unclassified')).toHaveLength(1);
|
|
});
|
|
it('does not warn unclassified when no port is published', () => {
|
|
expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0);
|
|
});
|
|
it('does not fabricate intent findings when the exposure context is unavailable', () => {
|
|
// A DB read failure leaves every intent null and no access URLs; the
|
|
// interpretation rules must stay silent rather than read that as unclassified
|
|
// or undocumented.
|
|
const rp = model([svc({ name: 'web', labelKeys: ['traefik.enable'], ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
|
const f = runRules(ctx({ model: rp, exposureAvailable: false }));
|
|
expect(ids(f, 'exposure-unclassified')).toHaveLength(0);
|
|
expect(ids(f, 'reverse-proxy-undocumented')).toHaveLength(0);
|
|
});
|
|
it('flags a published port absent from the documented access URLs', () => {
|
|
const f = runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([443]) }));
|
|
expect(ids(f, 'exposure-port-vs-dossier')).toHaveLength(1);
|
|
// No finding once the port is documented.
|
|
expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier')).toHaveLength(0);
|
|
// Gated off when the dossier records no access URL.
|
|
expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: false })), 'exposure-port-vs-dossier')).toHaveLength(0);
|
|
});
|
|
it('flags a published port absent from the documented access URLs, listing all undocumented ports', () => {
|
|
const m = model([svc({ name: 'web', ports: [
|
|
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
|
{ startPort: 9090, endPort: 9090, hostIp: '0.0.0.0', protocol: 'tcp' },
|
|
] })]);
|
|
const f = ids(runRules(ctx({ model: m, hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].message).toContain('9090');
|
|
expect(f[0].message).not.toContain('8080');
|
|
});
|
|
it('flags reverse-proxy labels with no documented URL or intent', () => {
|
|
const m = model([svc({ name: 'web', labelKeys: ['traefik.enable', 'traefik.http.routers.web.rule'] })]);
|
|
expect(ids(runRules(ctx({ model: m })), 'reverse-proxy-undocumented')).toHaveLength(1);
|
|
// A caddy-docker-proxy label also trips it.
|
|
const caddy = model([svc({ name: 'web', labelKeys: ['caddy', 'caddy.reverse_proxy'] })]);
|
|
expect(ids(runRules(ctx({ model: caddy })), 'reverse-proxy-undocumented')).toHaveLength(1);
|
|
// An unrelated vendor label that merely contains "caddy" does not.
|
|
const vendor = model([svc({ name: 'web', labelKeys: ['com.caddyserver.unrelated'] })]);
|
|
expect(ids(runRules(ctx({ model: vendor })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
|
// Silenced once documented, stack-intent reverse-proxy, or service-intent reverse-proxy.
|
|
expect(ids(runRules(ctx({ model: m, hasAccessUrls: true })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
|
expect(ids(runRules(ctx({ model: m, stackIntent: 'reverse-proxy' })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
|
expect(ids(runRules(ctx({ model: m, serviceIntents: { web: 'reverse-proxy' } })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
|
});
|
|
it('flags a sensitive image exposed on all interfaces', () => {
|
|
const m = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
|
expect(ids(runRules(ctx({ model: m })), 'sensitive-service-broad-exposure')[0].severity).toBe('high');
|
|
// A loopback bind of the same image does not flag.
|
|
const loop = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '127.0.0.1', protocol: 'tcp' }] })]);
|
|
expect(ids(runRules(ctx({ model: loop })), 'sensitive-service-broad-exposure')).toHaveLength(0);
|
|
// A build-only service with no image is not matched, even on a broad bind.
|
|
const build = model([svc({ name: 'postgres-ish', image: undefined, ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
|
expect(ids(runRules(ctx({ model: build })), 'sensitive-service-broad-exposure')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('node-state availability', () => {
|
|
// When the node's Docker snapshot could not be collected, the empty sets must not
|
|
// be read as "resource absent" or "no conflict". Every node-state rule suppresses
|
|
// itself, and one advisory explains the partial coverage.
|
|
const externalRes = model([svc()], {
|
|
networks: { ext: { name: 'shared', external: true, internal: false } },
|
|
volumes: { v: { name: 'data', external: true, internal: false } },
|
|
});
|
|
const newRes = model([svc()], {
|
|
networks: { backend: { name: 'backend', external: false, internal: false } },
|
|
volumes: { data: { name: 'data', external: false, internal: false } },
|
|
});
|
|
const portModel = model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp: '', protocol: 'tcp' }] })]);
|
|
const nameModel = model([svc({ containerName: 'taken' })]);
|
|
|
|
it('does not assert an external network/volume is absent', () => {
|
|
const f = runRules(ctx({ model: externalRes, nodeStateAvailable: false }));
|
|
expect(ids(f, 'external-network-missing')).toHaveLength(0);
|
|
expect(ids(f, 'external-volume-missing')).toHaveLength(0);
|
|
});
|
|
it('does not claim a network/volume is new', () => {
|
|
const f = runRules(ctx({ model: newRes, nodeStateAvailable: false }));
|
|
expect(ids(f, 'new-network')).toHaveLength(0);
|
|
expect(ids(f, 'new-volume')).toHaveLength(0);
|
|
});
|
|
it('does not report a clean all-clear over a real port conflict', () => {
|
|
const f = runRules(ctx({ model: portModel, nodeStateAvailable: false,
|
|
nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
|
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
|
|
});
|
|
it('does not report a clean all-clear over a real container_name collision', () => {
|
|
const f = runRules(ctx({ model: nameModel, nodeStateAvailable: false,
|
|
existingContainers: [{ name: 'taken', stack: 'other' }] }));
|
|
expect(ids(f, 'container-name-collision')).toHaveLength(0);
|
|
});
|
|
it('still runs node-state rules when the snapshot is available', () => {
|
|
const f = runRules(ctx({ model: externalRes, nodeStateAvailable: true }));
|
|
expect(ids(f, 'external-network-missing')).toHaveLength(1);
|
|
expect(ids(f, 'external-volume-missing')).toHaveLength(1);
|
|
});
|
|
it('does not suppress higher-severity model findings while node state is unavailable', () => {
|
|
const f = runRules(ctx({ model: model([svc({ privileged: true })]), nodeStateAvailable: false }));
|
|
expect(ids(f, 'node-state-unavailable')).toHaveLength(1);
|
|
expect(ids(f, 'privileged')).toHaveLength(1); // a real model finding is still reported alongside the advisory
|
|
});
|
|
|
|
describe('node-state-unavailable advisory', () => {
|
|
it('fires one info finding when the model rendered but node state is unavailable', () => {
|
|
const f = ids(runRules(ctx({ model: model([svc()]), nodeStateAvailable: false })), 'node-state-unavailable');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].severity).toBe('info');
|
|
});
|
|
it('stays silent when node state is available', () => {
|
|
expect(ids(runRules(ctx({ model: model([svc()]), nodeStateAvailable: true })), 'node-state-unavailable')).toHaveLength(0);
|
|
});
|
|
it('stays silent when the model is unrenderable (render-failed already covers it)', () => {
|
|
const f = runRules(ctx({ model: null, renderable: false, renderError: 'boom', nodeStateAvailable: false }));
|
|
expect(ids(f, 'node-state-unavailable')).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('self-managed-stack', () => {
|
|
it('fires a warning when the stack is the running Sencho instance', () => {
|
|
const f = ids(runRules(ctx({ isSelfStack: true })), 'self-managed-stack');
|
|
expect(f).toHaveLength(1);
|
|
expect(f[0].severity).toBe('warning');
|
|
});
|
|
it('stays silent for ordinary stacks', () => {
|
|
expect(ids(runRules(ctx({ isSelfStack: false })), 'self-managed-stack')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('rule registry completeness', () => {
|
|
// The canonical rule set. Adding or removing a rule must update this list,
|
|
// 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',
|
|
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck',
|
|
'healthcheck-disabled', 'healthcheck-inherited', 'healthcheck-unverifiable', 'healthcheck-inconsistent',
|
|
'deploy-swarm-only',
|
|
'node-state-unavailable',
|
|
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume',
|
|
'container-name-internal-dup', 'container-name-collision',
|
|
'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified',
|
|
'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded', 'self-managed-stack',
|
|
];
|
|
it('the registry contains exactly the expected rules', () => {
|
|
expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort());
|
|
});
|
|
});
|