feat: Compose Network Inspector and exposure intent guard (#1360)

* feat: add Compose Network Inspector facts engine

Render a stack's authored effective model and pair it with the live
Docker snapshot to derive per-stack networking facts: project networks
with external and internal flags, service-to-network membership and
aliases, published ports with host-binding scope, network_mode, and
extra_hosts, plus runtime drift (runtime-only attachments, foreign
networks, and declared-but-unused or missing networks).

Extend the effective-model parser with service network membership,
extra_hosts, and label keys (key names only, never values), and add a
key-space normalized network model with adapters from both the rendered
model and the raw declared compose so the Inspector and drift share one
comparison. Expose GET /api/stacks/:stackName/networking: advisory and
read-only, it renders the authored model only and never returns or logs
raw stderr, env values, or label values.

* feat: store and edit per-stack and per-service exposure intent

Add a stack_exposure_intent table (intent values constrained by a CHECK,
unique per node, stack, and service) with DAO methods to read, upsert,
clear one row, and clear all rows for a stack. The classification is
stored independently of the generated networking facts so a later
mismatch stays detectable; service rows are kept separately from the
stack-level row (service '').

Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read
access, PUT requires edit access and validates the intent against the
allowed set. Sending intent null clears that row, returning the scope to
unset so a service inherits the stack intent again. Intent rows are
cleared when the stack is deleted and when the owning node is removed,
so a later same-named stack never picks up stale classification.

* feat: add exposure-aware Compose Doctor findings

Feed the Compose Doctor's effective-model context with the stored
exposure intent (resolved into a stack-level value plus per-service
overrides) and the dossier's documented access-URL ports, read fail-soft
so a metadata read error skips these checks rather than failing the
preflight. Add five deterministic findings on top of that context:

- a service classified internal or same-node that publishes a host port
  (same-node tolerates a loopback bind),
- a sensitive database or admin image published on all interfaces,
- a port-publishing stack with no exposure intent set,
- a published port not reflected in the documented access URLs,
- reverse-proxy labels with no documented URL or reverse-proxy intent.

The rules stay pure functions over the preflight context; the registry
completeness test pins the new rule set.

* feat: detect compose network drift in the drift ledger

Extend the spatial drift engine with two network-level findings: a
running container attached to a stack-owned or foreign network that
compose does not declare (one finding per service), and a declared
network that no running service uses or that is absent from the runtime
(one stack-level finding, every network named by its resolved runtime
name). The comparison reuses the same helper the Network Inspector uses,
so the two surfaces never disagree.

Network drift runs only when the stack has running containers and the
runtime is reachable, preserving the existing missing-runtime,
parse-error, and unreachable behavior. The findings persist through the
existing drift ledger and surface on the Drift tab, which now labels the
two new kinds.

* feat: link a Docker network back to its owning stack

Add a cross-component open-stack event and make the owning-stack badge on
a managed network in Resources a link: clicking it loads that stack on
its node and opens the editor, reusing the existing fleet navigation. A
latest-ref keeps the window listener current without re-subscribing each
render. Image and volume badges are unchanged; only a managed network
opts in via the new optional handler.

* feat: add the Networking tab to the stack detail panel

Add a capability-gated Networking tab that reads the per-stack networking
facts and exposure intent. It shows the project networks (with external,
internal, and created-by-stack flags), per-service network membership and
aliases, published ports with their host-binding scope, network_mode and
extra_hosts, and runtime drift, degrading to the declared model when the
runtime is unavailable. Users can classify the stack and each service
(internal, LAN, reverse proxy, public, and so on) or clear a row to
inherit; the controls are read-only when the user cannot edit, and a
broken exposure response never tears down the facts view.

A new compose-networking capability is added to both registries so older
nodes hide the tab, and the tab cross-links to the Doctor for the deploy
and security findings.

* docs: document the Compose Networking tab

Add a feature page covering the Networking tab: the network facts,
published ports and host bindings, the exposure-intent classification
and inheritance, the exposure-aware Doctor findings, runtime drift, and
a troubleshooting section. Register it in the docs navigation next to
Compose Doctor.

* feat: add a redacted network summary to the Stack Dossier export

Append a network exposure section to the dossier Markdown: the stack and
per-service exposure intents, the networks with their external and
internal flags, and each service's published ports with their binding
scope. It carries only names, intents, port numbers, and scope, never an
env value or a label value.

The summary is fetched only when the user exports (copy or download), so
opening the panel costs nothing, and it degrades to omitting the section
when the data is unavailable. The whole-fleet dossier export collects the
same summary per stack, rethrowing the unauthorized sentinel like the
sibling loaders.

* feat: add a Fleet networking filter for exposure and drift

Add a per-node networking summary that classifies a node's stacks as
exposed (a host port published beyond loopback), unknown-exposure
(publishes ports with no exposure intent set), or network-drift. It
reads each stack's compose with the light dependency parser and one
Docker snapshot, so it stays cheap across a node's full stack set, and
it skips drift when the runtime is unreachable rather than inventing it.

Serve it node-locally at GET /api/networking/summary, and aggregate it
fleet-wide at GET /api/fleet/networking-summary: the hub computes its own
summary in-process and reaches each remote through its node-local route,
degrading an unreachable or older node to a skip. Because the aggregate
lives under the proxy-exempt /api/fleet prefix it is never wrongly
proxied. The Fleet overview gains a networking filter chip backed by that
aggregate, fetched fail-soft and detached so it never gates the grid.

* fix: spin the Networking refresh button while it reloads

The refresh button silently refetched the same data, so a click gave no
feedback. Track a refreshing state and spin the icon while the load is in
flight, disabling the button, matching the Compose Doctor preflight
button.

* fix: apply effective per-service exposure intent to unclassified checks

The "unclassified exposure" decisions only consulted the stack-level intent
row, so a service classified directly (with no stack row) was still reported
as unclassified, and a service explicitly marked unknown over a classified
stack was missed.

Both the exposure-unclassified preflight rule and the networking summary's
unknown-exposure bucket now resolve the effective intent per publishing
service (service row overrides stack row), matching the precedence already
used by the exposure-internal-published rule.

* fix: resolve drift network names via the compose top-level name

When a compose file sets a top-level name:, Docker prefixes resource names
with that project name instead of the stack directory. The light dependency
parser dropped name:, so network-drift normalization compared runtime
networks against directory-prefixed names and reported false
network-undeclared / network-missing findings.

Carry the parsed project name through DeclaredCompose and use it when
normalizing declared networks for drift, while still filtering containers by
the stack directory.
This commit is contained in:
Anso
2026-06-12 02:15:11 -04:00
committed by GitHub
parent bef51a979f
commit 77f1611971
53 changed files with 2853 additions and 49 deletions
@@ -221,3 +221,32 @@ describe('node deletion cleanup', () => {
expect(db().getPreflightFindings('run-x')).toEqual([]);
});
});
describe('exposure state feeds the exposure rules end to end', () => {
const ruleIds = (stack: string) => doctor().getLatest(nodeId, stack).findings.map(f => f.ruleId);
afterEach(() => {
db().deleteStackExposureIntents(nodeId, 'expe2e');
db().deleteStackDossier(nodeId, 'expe2e');
fs.rmSync(path.join(process.env.COMPOSE_DIR as string, 'expe2e'), { recursive: true, force: true });
});
it('fires exposure-internal-published from a stored stack intent', async () => {
writeStack('expe2e');
db().setStackExposureIntent(nodeId, 'expe2e', '', 'internal', 'tester');
stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} });
await doctor().runPreflight(nodeId, 'expe2e', 'tester');
expect(ruleIds('expe2e')).toContain('exposure-internal-published');
});
it('fires exposure-port-vs-dossier from the dossier access URLs', async () => {
writeStack('expe2e');
db().upsertStackDossier(nodeId, 'expe2e', {
purpose: '', owner: '', access_urls: 'https://app.example.com:443', static_ip: '', vlan: '',
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
});
stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} });
await doctor().runPreflight(nodeId, 'expe2e', 'tester');
expect(ruleIds('expe2e')).toContain('exposure-port-vs-dossier');
});
});
@@ -0,0 +1,247 @@
/**
* The Compose Network Inspector: the normalized-model adapters (rendered vs raw
* declared produce the same shape), the pure facts assembler, and the
* runtime-vs-Compose drift comparison (system/default/external networks and
* stopped containers are not flagged).
*/
import { describe, it, expect } from 'vitest';
import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel';
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController';
import {
fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts,
} from '../services/network/normalize';
import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector';
function effSvc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
};
}
function container(over: Partial<DependencyContainer> = {}): DependencyContainer {
return {
id: 'c1', name: 'web1', service: 'web', composeProject: 'myapp', stack: 'myapp',
state: 'running', image: 'nginx:1.27', networks: [], volumes: [], ports: [], ...over,
};
}
function depNet(over: Partial<DependencyNetwork> = {}): DependencyNetwork {
return { id: 'n', name: 'myapp_backend', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'myapp', stack: 'myapp', ...over };
}
describe('normalized-model adapters', () => {
it('rendered and raw declared models normalize to the same shape', () => {
const eff: EffectiveModel = {
projectName: 'myapp',
services: [effSvc({ name: 'web', networks: [{ key: 'backend', aliases: [] }, { key: 'shared', aliases: [] }] })],
networks: {
backend: { name: 'myapp_backend', external: false, internal: false },
shared: { name: 'shared_net', external: true, internal: false },
custom: { name: 'custom_name', external: false, internal: false },
},
volumes: {},
};
const declared: DeclaredCompose = {
services: [{ name: 'web', dependsOn: [], networks: ['backend', 'shared'], volumes: [], ports: [] }],
networks: {
backend: { external: false },
shared: { external: true, name: 'shared_net' },
custom: { external: false, name: 'custom_name' },
},
volumes: {},
};
expect(fromDeclaredCompose(declared, 'myapp')).toEqual(fromEffectiveModel(eff));
});
});
describe('assembleStackNetworkFacts', () => {
const model: EffectiveModel = {
projectName: 'myapp',
services: [effSvc({
name: 'web',
networks: [{ key: 'backend', aliases: ['www'] }],
extraHosts: ['host.docker.internal:host-gateway'],
ports: [
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
{ startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' },
],
})],
networks: {
default: { name: 'myapp_default', external: false, internal: false },
backend: { name: 'myapp_backend', external: false, internal: true },
shared: { name: 'shared_net', external: true, internal: false },
},
volumes: {},
};
it('reports networks with external/internal/createdByStack flags', () => {
const facts = assembleStackNetworkFacts('myapp', model, null, null);
expect(facts.renderable).toBe(true);
expect(facts.networks).toEqual([
{ key: 'default', name: 'myapp_default', external: false, internal: false, createdByStack: false },
{ key: 'backend', name: 'myapp_backend', external: false, internal: true, createdByStack: true },
{ key: 'shared', name: 'shared_net', external: true, internal: false, createdByStack: false },
]);
});
it('reports service membership, aliases, extra_hosts, and port binding flags', () => {
const svc = assembleStackNetworkFacts('myapp', model, null, null).services[0];
expect(svc.networks).toEqual([{ key: 'backend', aliases: ['www'] }]);
expect(svc.extraHosts).toEqual(['host.docker.internal:host-gateway']);
expect(svc.publishedPorts[0]).toMatchObject({ startPort: 8080, allInterfaces: true, loopbackOnly: false });
expect(svc.publishedPorts[1]).toMatchObject({ startPort: 9000, allInterfaces: false, loopbackOnly: true });
});
it('marks the runtime unavailable and leaves drift empty when there is no snapshot', () => {
const facts = assembleStackNetworkFacts('myapp', model, null, null);
expect(facts.runtime).toBe('unavailable');
expect(facts.drift.runtimeOnlyAttachments).toEqual([]);
});
it('returns a non-renderable facts payload when the model is null', () => {
const facts = assembleStackNetworkFacts('myapp', null, 'render failed', null);
expect(facts.renderable).toBe(false);
expect(facts.renderError).toBe('render failed');
expect(facts.networks).toEqual([]);
});
it('computes real drift through to the payload when a snapshot is present', () => {
const snapshot: DependencySnapshot = {
containers: [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_rogue', id: 'b', ip: '' }] })],
networks: [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_rogue' })],
volumes: [],
};
const facts = assembleStackNetworkFacts('myapp', model, null, snapshot);
expect(facts.runtime).toBe('available');
expect(facts.drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_rogue' }]);
});
});
describe('runtimeResourceName', () => {
it('uses a name override, else the project prefix', () => {
expect(runtimeResourceName('myapp', 'backend', undefined)).toBe('myapp_backend');
expect(runtimeResourceName('myapp', 'backend', 'backend')).toBe('myapp_backend'); // name == key is not an override
expect(runtimeResourceName('myapp', 'shared', 'shared_net')).toBe('shared_net');
});
});
describe('parseAccessUrlPorts', () => {
it('extracts host ports from access-URL text', () => {
expect([...parseAccessUrlPorts('http://host:8080/path and https://host:443')].sort((a, b) => a - b)).toEqual([443, 8080]);
});
it('finds no port when the URL has none (implicit scheme port)', () => {
expect([...parseAccessUrlPorts('https://app.example.com/dashboard')]).toEqual([]);
});
it('rejects out-of-range numbers and returns an empty set for empty input', () => {
expect([...parseAccessUrlPorts('http://host:99999')]).toEqual([]);
expect([...parseAccessUrlPorts('')]).toEqual([]);
});
});
describe('compareStackNetworks', () => {
const declared = fromEffectiveModel({
projectName: 'myapp',
services: [],
networks: {
backend: { name: 'myapp_backend', external: false, internal: false },
shared: { name: 'shared_net', external: true, internal: false },
},
volumes: {},
});
function snapshot(containers: DependencyContainer[], networks: DependencyNetwork[]): DependencySnapshot {
return { containers, networks, volumes: [] };
}
it('flags a runtime-only attachment to a stack-owned undeclared network', () => {
const snap = snapshot(
[container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_extra', id: 'b', ip: '' }] })],
[depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_extra' }), depNet({ name: 'shared_net', stack: null })],
);
const drift = compareStackNetworks(declared, snap, 'myapp');
expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_extra' }]);
});
it('flags a foreign network owned by another stack', () => {
const snap = snapshot(
[container({ networks: [{ name: 'other_net', id: 'x', ip: '' }] })],
[depNet({ name: 'other_net', stack: 'other', composeProject: 'other' })],
);
const drift = compareStackNetworks(declared, snap, 'myapp');
expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'other_net' }]);
});
it('treats a stack-owned network with no project prefix as runtime-only (ownership via snapshot.stack)', () => {
const snap = snapshot(
[container({ networks: [{ name: 'odd-named-net', id: 'x', ip: '' }] })],
[depNet({ name: 'odd-named-net', stack: 'myapp', composeProject: 'myapp' })],
);
const drift = compareStackNetworks(declared, snap, 'myapp');
expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'odd-named-net' }]);
expect(drift.foreignNetworkAttachments).toEqual([]);
});
it('treats an attachment to a network absent from the snapshot as foreign', () => {
const snap = snapshot(
[container({ networks: [{ name: 'ghost-net', id: 'x', ip: '' }] })],
[], // ghost-net is not in the snapshot network list
);
const drift = compareStackNetworks(declared, snap, 'myapp');
expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'ghost-net' }]);
});
it('drives the declared-compose adapter through the comparison (name override resolves)', () => {
const declaredFromCompose = fromDeclaredCompose({
services: [{ name: 'web', dependsOn: [], networks: ['edge'], volumes: [], ports: [] }],
networks: { edge: { external: false, name: 'edge_override' } },
volumes: {},
}, 'myapp');
const snap = snapshot(
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
[depNet({ name: 'myapp_default' })],
);
// edge_override is declared but missing from the runtime.
expect(compareStackNetworks(declaredFromCompose, snap, 'myapp').missingFromRuntime).toEqual(['edge_override']);
});
it('ignores system networks, the default network, and external networks', () => {
const snap = snapshot(
[container({ networks: [
{ name: 'bridge', id: 's', ip: '' },
{ name: 'myapp_default', id: 'd', ip: '' },
{ name: 'shared_net', id: 'e', ip: '' },
] })],
[depNet({ name: 'bridge', isSystem: true, stack: null }), depNet({ name: 'myapp_default' }), depNet({ name: 'shared_net', stack: null })],
);
const drift = compareStackNetworks(declared, snap, 'myapp');
expect(drift.runtimeOnlyAttachments).toEqual([]);
expect(drift.foreignNetworkAttachments).toEqual([]);
});
it('does not flag attachments from stopped containers', () => {
const snap = snapshot(
[container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })],
[depNet({ name: 'myapp_extra' })],
);
expect(compareStackNetworks(declared, snap, 'myapp').runtimeOnlyAttachments).toEqual([]);
});
it('reports a declared network no running service uses', () => {
const snap = snapshot(
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
[depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_default' })],
);
expect(compareStackNetworks(declared, snap, 'myapp').declaredButUnused).toEqual(['backend']);
});
it('reports a declared network missing from the runtime', () => {
const snap = snapshot(
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
[depNet({ name: 'myapp_default' })],
);
expect(compareStackNetworks(declared, snap, 'myapp').missingFromRuntime).toEqual(['myapp_backend']);
});
});
+121 -1
View File
@@ -11,7 +11,7 @@ import {
buildStackDriftReport,
} from '../services/DriftDetectionService';
import DockerController from '../services/DockerController';
import type { DependencyContainer, DependencySnapshot } from '../services/DockerController';
import type { DependencyContainer, DependencyNetwork, DependencySnapshot } from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse';
@@ -250,6 +250,107 @@ describe('assembleStackDrift - findings', () => {
});
});
// ── assembleStackDrift: network drift ─────────────────────────────────────
function depNet(name: string, p: Partial<DependencyNetwork> = {}): DependencyNetwork {
return { id: name, name, driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app', ...p };
}
describe('assembleStackDrift - network drift', () => {
it('flags a runtime-only attachment to a stack-owned undeclared network', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_default', id: 'd', ip: '' }, { name: 'app_extra', id: 'e', ip: '' }] })],
networks: [depNet('app_default'), depNet('app_extra')],
});
const f = report.findings.find(x => x.kind === 'network-undeclared');
expect(f).toMatchObject({ service: 'web', actual: 'app_extra' });
expect(f?.detail).not.toContain('app_default'); // the implicit default is declared
});
it('maps a foreign network attachment back to its service (not the container name)', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
// Distinct container name vs service proves the service-map lookup ran, not the name fallback.
containers: [container({ id: 'c1', name: 'app-web-1', service: 'web', networks: [{ name: 'other_net', id: 'o', ip: '' }] })],
networks: [depNet('other_net', { stack: 'other', composeProject: 'other' })],
});
expect(report.findings.find(x => x.kind === 'network-undeclared')).toMatchObject({ service: 'web', actual: 'other_net' });
});
it('aggregates multiple undeclared networks on one service into a single finding', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_extra1', id: '1', ip: '' }, { name: 'app_extra2', id: '2', ip: '' }] })],
networks: [depNet('app_extra1'), depNet('app_extra2')],
});
const f = report.findings.filter(x => x.kind === 'network-undeclared');
expect(f).toHaveLength(1);
expect(f[0]).toMatchObject({ service: 'web', actual: 'app_extra1, app_extra2' });
expect(f[0].detail).toContain('networks not declared'); // plural wording
});
it('flags a declared network that no running service uses, by its runtime name', () => {
const report = assembleStackDrift({
stack: 'app',
declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false } }, volumes: {} },
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })],
networks: [depNet('app_frontend'), depNet('app_backend')],
});
expect(report.findings.find(x => x.kind === 'network-missing')).toMatchObject({ service: '', expected: 'app_backend' });
});
it('reports unused and absent declared networks together in one consistent namespace', () => {
const report = assembleStackDrift({
stack: 'app',
declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false }, gamma: { external: false } }, volumes: {} },
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })],
// app_backend exists but is unused; app_gamma is absent from the runtime.
networks: [depNet('app_frontend'), depNet('app_backend')],
});
const f = report.findings.filter(x => x.kind === 'network-missing');
expect(f).toHaveLength(1);
expect(f[0].expected).toBe('app_backend, app_gamma'); // both as runtime names, not mixed keys
});
it('ignores system and default networks', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'bridge', id: 'b', ip: '' }, { name: 'app_default', id: 'd', ip: '' }] })],
networks: [depNet('bridge', { isSystem: true, stack: null }), depNet('app_default')],
});
expect(report.findings.filter(x => x.kind.startsWith('network-'))).toEqual([]);
});
it('reports no network drift for a stopped stack', () => {
const report = assembleStackDrift({
stack: 'app',
declared: declared([service({ name: 'web' })]),
containers: [container({ id: 'c1', service: 'web', state: 'exited', networks: [{ name: 'app_extra', id: 'e', ip: '' }] })],
networks: [depNet('app_extra')],
});
expect(report.status).toBe('missing-runtime');
expect(report.findings).toEqual([]);
});
it('resolves runtime network names via the compose top-level name (no false drift)', () => {
// Stack dir is "app" but the compose declares `name: acme`, so Docker names
// the network acme_backend. With the project name carried through, that
// matches and produces no network-undeclared / network-missing.
const report = assembleStackDrift({
stack: 'app',
declared: { services: [service({ name: 'web', networks: ['backend'] })], networks: { backend: { external: false } }, volumes: {}, projectName: 'acme' },
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'acme_default', id: 'd', ip: '' }, { name: 'acme_backend', id: 'b', ip: '' }] })],
networks: [depNet('acme_default'), depNet('acme_backend')],
});
expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]);
});
});
// ── normalizeImageRef ─────────────────────────────────────────────────────
describe('normalizeImageRef', () => {
@@ -321,4 +422,23 @@ describe('buildStackDriftReport - boundaries', () => {
expect(findingKinds(report)).toEqual(['image-mismatch']);
vi.restoreAllMocks();
});
it('threads the snapshot networks through into a network-undeclared finding', async () => {
const snapshot: DependencySnapshot = {
containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'app_rogue', id: 'r', ip: '' }] })],
networks: [depNet('app_rogue')],
volumes: [],
};
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStackContent: vi.fn().mockResolvedValue('services:\n web:\n image: nginx:1.25\n'),
getStacks: vi.fn().mockResolvedValue(['app']),
} as unknown as FileSystemService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue(snapshot),
} as unknown as DockerController);
const report = await buildStackDriftReport(0, 'app');
expect(findingKinds(report)).toContain('network-undeclared');
vi.restoreAllMocks();
});
});
@@ -0,0 +1,105 @@
/**
* GET/PUT /api/stacks/:stackName/exposure: read and write the per-stack and
* per-service exposure classification. PUT requires write access, validates the
* intent value, and supports clearing a row (intent null) so a service inherits
* the stack intent again.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { generateApiToken } from '../utils/apiTokenFormat';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let readOnlyToken: string;
const STACK = 'exproute';
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
const db = DatabaseService.getInstance();
readOnlyToken = generateApiToken();
db.addApiToken({
token_hash: crypto.createHash('sha256').update(readOnlyToken).digest('hex'),
name: 'exposure-readonly', scope: 'read-only',
user_id: db.getUserByUsername(TEST_USERNAME)!.id, created_at: Date.now(), expires_at: null,
});
});
afterAll(() => cleanupTestDb(tmpDir));
describe('exposure intent routes', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
});
afterEach(() => {
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
fs.rmSync(stackDir, { recursive: true, force: true });
});
const put = (body: object) => request(app).put(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader).send(body);
const get = () => request(app).get(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader);
it('starts with no intents', async () => {
const res = await get();
expect(res.status).toBe(200);
expect(res.body.intents).toEqual([]);
});
it('sets a stack-level and a per-service intent and records the author', async () => {
expect((await put({ intent: 'internal' })).status).toBe(200);
const res = await put({ service: 'api', intent: 'public' });
expect(res.status).toBe(200);
expect(res.body.intents).toEqual([
expect.objectContaining({ service: '', intent: 'internal' }),
expect.objectContaining({ service: 'api', intent: 'public' }),
]);
const stackRow = res.body.intents.find((i: { service: string }) => i.service === '');
expect(stackRow.updatedBy).toBe(TEST_USERNAME);
expect(typeof stackRow.updatedAt).toBe('number');
});
it('clears a service row (intent null) so it inherits the stack intent', async () => {
await put({ intent: 'internal' });
await put({ service: 'api', intent: 'public' });
expect((await put({ service: 'api', intent: null })).status).toBe(200);
const res = await get();
expect(res.body.intents).toEqual([expect.objectContaining({ service: '', intent: 'internal' })]);
});
it('clears the stack row (intent null) so the stack is unclassified again', async () => {
await put({ intent: 'internal' });
await put({ intent: null });
expect((await get()).body.intents).toEqual([]);
});
it('rejects an invalid intent value', async () => {
expect((await put({ intent: 'bogus' })).status).toBe(400);
});
it('blocks a write from a read-only token', async () => {
const res = await request(app).put(`/api/stacks/${STACK}/exposure`)
.set('Authorization', `Bearer ${readOnlyToken}`).send({ intent: 'internal' });
expect(res.status).toBe(403);
});
it('rejects an unauthenticated request', async () => {
expect((await request(app).get(`/api/stacks/${STACK}/exposure`)).status).toBe(401);
});
it('returns 404 for a stack that does not exist', async () => {
expect((await request(app).get('/api/stacks/nope-not-here/exposure').set('Authorization', authHeader)).status).toBe(404);
});
});
@@ -0,0 +1,98 @@
/**
* GET /api/stacks/:stackName/networking: returns facts, requires stack:read,
* 404s a missing stack, surfaces a structural (never raw) error on render
* failure, and never leaks an env value or a label value into the response.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'netroute';
const ENV_SECRET = 'env-secret-44ad-value';
const LABEL_SECRET = 'label-secret-90fe-value';
function stubRender(rendered: string | null, stderr = '') {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
} as unknown as ComposeService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('networking route', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('returns networking facts for a renderable stack', async () => {
stubRender(JSON.stringify({
name: STACK,
services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], networks: { backend: null } } },
networks: { backend: { name: `${STACK}_backend` } },
volumes: {},
}));
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.renderable).toBe(true);
expect(res.body.runtime).toBe('available');
expect(res.body.services[0].networks).toEqual([{ key: 'backend', aliases: [] }]);
});
it('surfaces a structural error and never raw stderr on render failure', async () => {
stubRender(null, `error: the "${ENV_SECRET}" variable is not set\nservices.web.image: ${ENV_SECRET}`);
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.renderable).toBe(false);
expect(JSON.stringify(res.body)).not.toContain(ENV_SECRET);
});
it('never leaks env or label values into the facts', async () => {
stubRender(JSON.stringify({
name: STACK,
services: { web: { image: 'nginx:latest', environment: { TOKEN: ENV_SECRET }, labels: { 'x.secret': LABEL_SECRET } } },
networks: {},
volumes: {},
}));
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(ENV_SECRET);
expect(body).not.toContain(LABEL_SECRET);
});
it('rejects an unauthenticated request', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/networking`);
expect(res.status).toBe(401);
});
it('returns 404 for a stack that does not exist', async () => {
stubRender(JSON.stringify({ name: 'x', services: {}, networks: {}, volumes: {} }));
const res = await request(app).get('/api/stacks/nope-not-here/networking').set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,140 @@
/**
* The per-node networking summary and its routes: a stack that publishes a
* non-loopback port counts as exposed and, with no intent set, as
* unknown-exposure; the node-local route and the proxy-exempt fleet aggregate
* both return it.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'netsummary';
function stubSnapshot() {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('networking summary', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "0.0.0.0:8080:80"\n');
stubSnapshot();
});
afterEach(() => {
vi.restoreAllMocks();
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('node-local summary marks a published stack exposed and unknown-exposure', async () => {
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.exposed.stacks).toContain(STACK);
expect(res.body.unknownExposure.stacks).toContain(STACK);
expect(res.body.networkDrift.stacks).toEqual([]); // empty snapshot, no running containers
expect(res.body.exposed.count).toBe(res.body.exposed.stacks.length);
});
it('flags a stack with an undeclared runtime network as network drift', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({
containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: `${STACK}_default`, id: 'd', ip: '' }, { name: `${STACK}_rogue`, id: 'r', ip: '' }], volumes: [], ports: [] }],
networks: [
{ id: 'd', name: `${STACK}_default`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK },
{ id: 'r', name: `${STACK}_rogue`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK },
],
volumes: [],
}),
} as unknown as DockerController);
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.body.networkDrift.stacks).toContain(STACK);
});
it('still reports declared signals when the snapshot is unavailable (drift skipped)', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')),
} as unknown as DockerController);
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.exposed.stacks).toContain(STACK);
expect(res.body.networkDrift.stacks).toEqual([]);
});
it('drops the stack from unknown-exposure once an intent is set', async () => {
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'public', 'admin');
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.body.exposed.stacks).toContain(STACK);
expect(res.body.unknownExposure.stacks).not.toContain(STACK);
});
it('a service-level intent on the only publishing service drops it from unknown-exposure', async () => {
// No stack-level row; classifying the publishing service is enough.
DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin');
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.body.unknownExposure.stacks).not.toContain(STACK);
});
it('keeps the stack unknown when only some publishing services are classified', async () => {
fs.writeFileSync(path.join(stackDir, 'compose.yaml'),
'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n api:\n image: nginx:latest\n ports:\n - "9090:90"\n');
// web classified, api still unset, so the stack remains effectively unknown.
DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin');
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
expect(res.body.unknownExposure.stacks).toContain(STACK);
});
it('the fleet aggregate returns a per-node summary for the hub', async () => {
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.nodes)).toBe(true);
const local = res.body.nodes.find((n: { status: string; summary: { exposed: { stacks: string[] } } | null }) => n.summary?.exposed.stacks.includes(STACK));
expect(local).toBeDefined();
expect(local.status).toBe('ok');
expect(local.summary.unknownExposure).toBeDefined();
expect(local.summary.networkDrift).toBeDefined();
});
it('degrades a remote that errors to a node-error while keeping the hub', async () => {
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 }));
try {
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
expect(res.status).toBe(200);
const hub = res.body.nodes.find((n: { summary: unknown }) => n.summary !== null);
const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === remoteId);
expect(hub.status).toBe('ok');
expect(remote.status).toBe('error');
expect(remote.summary).toBeNull();
} finally {
db.deleteNode(remoteId);
}
});
it('rejects an unauthenticated request to the node-local summary', async () => {
expect((await request(app).get('/api/networking/summary')).status).toBe(401);
});
});
@@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest';
import { parseEffectiveModel } from '../services/preflight/effectiveModel';
const SECRET = 'topsecret-9f3a-value';
const LABEL_SECRET = 'label-secret-7b21-value';
function render() {
return {
@@ -31,10 +32,14 @@ function render() {
container_name: 'web1',
user: '1000:1000',
environment: { DB_PASSWORD: SECRET, PUID: '1000' },
networks: { backend: { aliases: ['www', 'web'] }, shared: null },
extra_hosts: ['host.docker.internal:host-gateway'],
labels: { 'traefik.enable': 'true', 'secret.label': LABEL_SECRET },
},
},
networks: {
default: { name: 'myapp_default' },
backend: { name: 'myapp_backend', internal: true },
shared: { name: 'shared_net', external: true },
},
volumes: {
@@ -71,12 +76,49 @@ describe('parseEffectiveModel', () => {
]);
});
it('resolves top-level networks and volumes with external flags', () => {
it('resolves top-level networks and volumes with external and internal flags', () => {
const m = parseEffectiveModel(render(), 'fallback');
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true });
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false });
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true });
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false });
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true, internal: false });
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false, internal: false });
expect(m.networks.backend).toEqual({ name: 'myapp_backend', external: false, internal: true });
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true, internal: false });
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false });
});
it('parses service network membership (key-space) with aliases', () => {
const web = parseEffectiveModel(render(), 'fallback').services[0];
expect(web.networks).toEqual([
{ key: 'backend', aliases: ['www', 'web'] },
{ key: 'shared', aliases: [] },
]);
});
it('parses service network membership from the list form', () => {
const m = parseEffectiveModel({ services: { s: { networks: ['frontend', 'backend'] } } }, 'p');
expect(m.services[0].networks).toEqual([
{ key: 'frontend', aliases: [] },
{ key: 'backend', aliases: [] },
]);
});
it('parses extra_hosts in list and map form', () => {
const web = parseEffectiveModel(render(), 'fallback').services[0];
expect(web.extraHosts).toEqual(['host.docker.internal:host-gateway']);
const mapForm = parseEffectiveModel({ services: { s: { extra_hosts: { 'db.local': '10.0.0.5' } } } }, 'p');
expect(mapForm.services[0].extraHosts).toEqual(['db.local:10.0.0.5']);
});
it('reads label KEY names only, never label values', () => {
const web = parseEffectiveModel(render(), 'fallback').services[0];
expect(web.labelKeys).toEqual(['traefik.enable', 'secret.label']);
// A label value can carry a secret; it must not survive into the model.
expect(JSON.stringify(parseEffectiveModel(render(), 'fallback'))).not.toContain(LABEL_SECRET);
});
it('reads label key names from the list form without keeping the value', () => {
const m = parseEffectiveModel({ services: { s: { labels: [`secret.label=${LABEL_SECRET}`, 'plain'] } } }, 'p');
expect(m.services[0].labelKeys).toEqual(['secret.label', 'plain']);
expect(JSON.stringify(m)).not.toContain(LABEL_SECRET);
});
it('reads environment KEY names only, never values', () => {
+97 -6
View File
@@ -13,7 +13,8 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t
function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], ...over,
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
};
}
@@ -27,7 +28,8 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
existingContainers: [], bindChecks: [], ...over,
existingContainers: [], bindChecks: [],
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, ...over,
};
}
@@ -178,18 +180,18 @@ describe('hygiene rules', () => {
describe('network / volume rules', () => {
it('blocks a missing external network and volume', () => {
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } }, volumes: { v: { name: 'data', external: true } } });
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 } } });
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', () => {
const m = model([svc()], { networks: { backend: { name: 'backend', external: false } }, volumes: { data: { name: 'data', external: false } } });
const m = model([svc()], { 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].severity).toBe('info');
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
@@ -231,6 +233,93 @@ describe('effective-model-expanded', () => {
});
});
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('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('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.
@@ -239,7 +328,9 @@ describe('rule registry completeness', () => {
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
'container-name-internal-dup', 'container-name-collision', 'effective-model-expanded',
'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',
];
it('the registry contains exactly the expected rules', () => {
expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort());
@@ -0,0 +1,56 @@
/**
* Stack exposure-intent DAO: stack-level ('') and per-service rows, upsert,
* single-row clear, clear-all, the CHECK constraint, and node-delete cleanup.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
let tmpDir: string;
let db: DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
db = DatabaseService.getInstance();
});
afterAll(() => cleanupTestDb(tmpDir));
describe('stack exposure intent DAO', () => {
it('stores and reads stack-level and per-service intents', () => {
db.setStackExposureIntent(1, 'web', '', 'internal', 'admin');
db.setStackExposureIntent(1, 'web', 'api', 'public', 'admin');
const rows = db.getStackExposureIntents(1, 'web');
expect(rows.map(r => [r.service, r.intent])).toEqual([['', 'internal'], ['api', 'public']]);
expect(rows[0].updated_by).toBe('admin');
});
it('upserts an existing row in place', () => {
db.setStackExposureIntent(1, 'web', '', 'lan', 'admin2');
const stack = db.getStackExposureIntents(1, 'web').find(r => r.service === '');
expect(stack?.intent).toBe('lan');
expect(stack?.updated_by).toBe('admin2');
});
it('clears one row and clears all rows for a stack', () => {
db.deleteStackExposureIntent(1, 'web', 'api');
expect(db.getStackExposureIntents(1, 'web').map(r => r.service)).toEqual(['']);
db.deleteStackExposureIntents(1, 'web');
expect(db.getStackExposureIntents(1, 'web')).toEqual([]);
});
it('rejects an out-of-range intent via the CHECK constraint', () => {
const raw = (db as unknown as { db: import('better-sqlite3').Database }).db;
expect(() => raw
.prepare("INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at) VALUES (1, 'x', '', 'bogus', 1)")
.run()).toThrow(/CHECK constraint failed/);
});
it('removes intent rows when the owning node is deleted', () => {
const nodeId = db.addNode({ name: 'expnode', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://x', api_token: 't' });
db.setStackExposureIntent(nodeId, 'svc', '', 'public', null);
expect(db.getStackExposureIntents(nodeId, 'svc')).toHaveLength(1);
db.deleteNode(nodeId);
expect(db.getStackExposureIntents(nodeId, 'svc')).toEqual([]);
});
});