Files
sencho/backend/src/__tests__/exposure.test.ts
T
Anso 3a22f59057 feat(security): surface Compose internet-reachability exposure in posture (#1442)
* feat(security): surface Compose internet-reachability exposure in posture

Builds a per-stack per-service exposure descriptor from the rendered
effective Compose model, cached at deploy/update time, and joins it into
the Security action posture. A service is publicly exposed when it
publishes a port on a non-loopback host IP or uses host networking.

The exposure cache lives in a new stack_exposure table, refreshed inside
ComposeService.deployStack and updateStack (covering all funneled paths:
manual, scheduler, mesh, templates, labels, App Store, Git, webhooks).
Cleanup runs on stack delete, blueprint withdrawal, and node delete.

The overview route intersects the exposed image set with the existing
per-image suppression-aware Critical/High tally, so a clean public
nginx does not escalate posture. The scan sheet shows a "Published
service" or "Internal only" evidence badge per image.

* fix(test): provide fresh auto-close proc for exposure spawn in stall tests

Two deployStack idle-stall tests used mockSpawn.mockReturnValue(proc)
which returned the same already-closed process for the new config spawn
added by the exposure refresh. The renderConfig promise hung waiting for
a close event that had already fired.

The fix uses mockImplementation to return the controlled proc for the
first spawn (up) and a fresh auto-closing proc for the second spawn
(config via refreshExposureCache).

* fix(security): tighten loopback detection, clarify exposure semantics, drop internal-only badge

- Expand isLoopback to cover full 127.0.0.0/8 range (127.0.0.2 etc)
- Clarify that exposure is configured (Compose model), not live topology
- Remove "Internal only" badge: false is not proof of non-exposure when
  other stacks using the same image may lack a cached descriptor
2026-06-24 23:22:13 -04:00

306 lines
9.5 KiB
TypeScript

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