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
@@ -37,6 +37,7 @@ export const CAPABILITIES = [
'vulnerability-scanning',
'compose-doctor',
'update-guard',
'compose-networking',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
@@ -9,6 +9,8 @@ import { DatabaseService } from './DatabaseService';
import { computeStackHashes } from './DriftLedgerService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { parseAccessUrlPorts } from './network/normalize';
import type { ExposureIntent } from './network/types';
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
import type {
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus,
@@ -196,6 +198,7 @@ export class ComposeDoctorService {
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers } = await this.nodeState(nodeId, fsSvc, stackName);
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
return {
stackName,
@@ -211,9 +214,46 @@ export class ComposeDoctorService {
existingVolumeNames,
existingContainers,
bindChecks,
stackIntent,
serviceIntents,
accessUrlPorts,
hasAccessUrls,
};
}
/**
* The user's stored exposure intent (resolved into stack-level + per-service)
* and the dossier's documented access-URL ports, for the exposure rules.
* Fail-soft: a read error defaults to unset/empty so the rules simply do not
* fire rather than the whole preflight failing.
*/
private exposureState(nodeId: number, stackName: string): {
stackIntent: ExposureIntent | null;
serviceIntents: Record<string, ExposureIntent>;
accessUrlPorts: Set<number>;
hasAccessUrls: boolean;
} {
try {
const db = DatabaseService.getInstance();
const rows = db.getStackExposureIntents(nodeId, stackName);
const stackIntent = rows.find(r => r.service === '')?.intent ?? null;
const serviceIntents: Record<string, ExposureIntent> = {};
for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent;
const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? '';
return {
stackIntent,
serviceIntents,
accessUrlPorts: parseAccessUrlPorts(accessUrls),
hasAccessUrls: accessUrls.trim().length > 0,
};
} catch (error) {
console.warn('[ComposeDoctor] Exposure state unavailable for %s; exposure rules skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false };
}
}
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{
nodePorts: NodePortBinding[];
+57
View File
@@ -4,6 +4,7 @@ import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
function isPilotMode(): boolean {
return process.env.SENCHO_MODE === 'pilot';
@@ -152,6 +153,17 @@ export interface StackDriftFindingRow {
resolved_at: number | null;
}
export interface StackExposureIntentRow {
id: number;
node_id: number;
stack_name: string;
/** '' = the stack-level classification; otherwise a service name. */
service: string;
intent: ExposureIntent;
updated_at: number;
updated_by: string | null;
}
export interface Node {
id: number;
name: string;
@@ -1261,6 +1273,19 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open
ON stack_drift_findings(node_id, stack_name, resolved_at);
CREATE TABLE IF NOT EXISTS stack_exposure_intent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
service TEXT NOT NULL DEFAULT '',
intent TEXT NOT NULL CHECK(intent IN (${EXPOSURE_INTENTS.map(i => `'${i}'`).join(', ')})),
updated_at INTEGER NOT NULL,
updated_by TEXT,
UNIQUE(node_id, stack_name, service)
);
CREATE INDEX IF NOT EXISTS idx_stack_exposure_intent_stack
ON stack_exposure_intent(node_id, stack_name);
CREATE TABLE IF NOT EXISTS preflight_runs (
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
@@ -2298,6 +2323,37 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Stack Exposure Intent (per-stack and per-service exposure classification) ---
/** All intent rows for a stack: the stack-level row (service '') and any per-service rows. */
public getStackExposureIntents(nodeId: number, stackName: string): StackExposureIntentRow[] {
return this.db.prepare(
'SELECT * FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? ORDER BY service ASC'
).all(nodeId, stackName) as StackExposureIntentRow[];
}
/** Upsert one intent row. `service` is '' for the stack-level classification. */
public setStackExposureIntent(nodeId: number, stackName: string, service: string, intent: ExposureIntent, updatedBy: string | null): void {
this.db.prepare(
`INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(node_id, stack_name, service) DO UPDATE SET
intent = excluded.intent,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`
).run(nodeId, stackName, service, intent, Date.now(), updatedBy);
}
/** Clear one intent row, leaving that scope unset; consumers treat a service with no row as inheriting the stack intent. */
public deleteStackExposureIntent(nodeId: number, stackName: string, service: string): void {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? AND service = ?').run(nodeId, stackName, service);
}
/** Clear every intent row for a stack (used when the stack is deleted). */
public deleteStackExposureIntents(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Compose Doctor / Preflight ---
/** Store a run and its findings, replacing any prior run for this (node, stack). */
@@ -2730,6 +2786,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
+73 -3
View File
@@ -1,8 +1,9 @@
import DockerController from './DockerController';
import type { DependencyContainer } from './DockerController';
import type { DependencyContainer, DependencyNetwork } from './DockerController';
import { FileSystemService } from './FileSystemService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse';
import { compareStackNetworks, fromDeclaredCompose } from './network/normalize';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
@@ -25,7 +26,9 @@ export type DriftFindingKind =
| 'service-missing'
| 'service-undeclared'
| 'image-mismatch'
| 'ports-mismatch';
| 'ports-mismatch'
| 'network-undeclared'
| 'network-missing';
export interface StackDriftFinding {
kind: DriftFindingKind;
@@ -101,10 +104,72 @@ export interface AssembleStackDriftInput {
declared: DeclaredCompose;
/** All runtime containers belonging to this stack (any state). */
containers: DependencyContainer[];
/** Every network on the node (for resolving foreign vs stack-owned attachments). */
networks?: DependencyNetwork[];
/** Set when the compose file could not be parsed. */
parseError?: string;
}
/** Add a network to a service's accumulated undeclared-attachment set. */
function addNetwork(map: Map<string, Set<string>>, service: string, network: string): void {
let set = map.get(service);
if (!set) { set = new Set(); map.set(service, set); }
set.add(network);
}
/**
* Network-level drift: a service attached to a network not declared in compose
* (stack-owned-undeclared or owned by another stack) is one finding per service;
* declared networks that no running service uses or that are absent from the
* runtime are one stack-level finding. Reuses the same comparison the Network
* Inspector uses, so the two surfaces never disagree.
*/
function networkDriftFindings(
stack: string,
declared: DeclaredCompose,
containers: DependencyContainer[],
networks: DependencyNetwork[],
): StackDriftFinding[] {
// Runtime resource names use the Compose project (top-level `name:` when set),
// not the stack directory, so a stack with `name:` resolves its networks the
// same way Docker does. Containers are still attributed to the stack directory.
const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack);
const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack);
const findings: StackDriftFinding[] = [];
const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name]));
const undeclaredByService = new Map<string, Set<string>>();
for (const a of facts.runtimeOnlyAttachments) addNetwork(undeclaredByService, a.service ?? a.container, a.network);
for (const a of facts.foreignNetworkAttachments) addNetwork(undeclaredByService, serviceByContainer.get(a.container) ?? a.container, a.network);
for (const [service, nets] of undeclaredByService) {
const list = [...nets].sort();
const joined = list.join(', ');
findings.push({
kind: 'network-undeclared',
service,
detail: `Service "${service}" is attached to ${list.length > 1 ? 'networks' : 'a network'} not declared in compose: ${joined}.`,
actual: joined,
});
}
// declaredButUnused holds compose keys; resolve them to runtime names so the
// finding lists every missing network in one consistent namespace.
const unusedNames = facts.declaredButUnused.map(key => normalized.networks[key]?.runtimeName ?? key);
const missing = [...new Set([...unusedNames, ...facts.missingFromRuntime])].sort();
if (missing.length > 0) {
const joined = missing.join(', ');
findings.push({
kind: 'network-missing',
service: '',
detail: `Declared ${missing.length > 1 ? 'networks are' : 'network is'} unused by running services or absent from the runtime: ${joined}.`,
expected: joined,
});
}
return findings;
}
/**
* Pure diff step (no Docker / FS access) so it is directly unit-testable. Only
* running containers are compared, since a stopped container publishes no ports
@@ -115,6 +180,7 @@ export interface AssembleStackDriftInput {
*/
export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport {
const { stack, declared, containers, parseError } = input;
const networks = input.networks ?? [];
const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state));
// A parse failure means the declared model is untrustworthy: report drift
@@ -204,6 +270,8 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe
}
}
findings.push(...networkDriftFindings(stack, declared, containers, networks));
const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync';
return { stack, status, hasComposeFile: true, hasContainers, findings };
}
@@ -235,6 +303,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
const declared = parseComposeDependencies(content);
let containers: DependencyContainer[];
let networks: DependencyNetwork[] = [];
try {
// The snapshot needs the full known-stacks set to resolve each container to
// its stack; we then filter to this one. Do not narrow to [stackName] or
@@ -242,6 +311,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
const stacks = await fs.getStacks();
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
containers = snapshot.containers.filter((c) => c.stack === stackName);
networks = snapshot.networks;
} catch (error) {
// Docker is unreachable, so runtime drift cannot be assessed. The headline
// failure is reachability; a separate parse error (if any) surfaces as
@@ -256,5 +326,5 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
};
}
return assembleStackDrift({ stack: stackName, declared, containers, parseError: declared.parseError });
return assembleStackDrift({ stack: stackName, declared, containers, networks, parseError: declared.parseError });
}
@@ -0,0 +1,118 @@
/**
* Compose Network Inspector: renders the authored effective model and pairs it
* with the live Docker snapshot to produce the per-stack networking facts a
* Community user reads (network map, membership, published ports/bindings,
* network_mode, extra_hosts, and runtime drift). Advisory and read-only; it
* renders the AUTHORED model only (no Mesh overrides) and never returns or logs
* raw docker stderr, env values, or label values.
*/
import DockerController, { type DependencySnapshot } from '../DockerController';
import { ComposeService } from '../ComposeService';
import { FileSystemService } from '../FileSystemService';
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
import { parseMissingRequiredVars } from '../ComposeDoctorService';
import {
compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback,
} from './normalize';
import type {
NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts,
} from './types';
import { getErrorMessage } from '../../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog';
const MAX_RENDER_ERROR = 600;
const EMPTY_DRIFT: NetworkDriftFacts = {
runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [],
};
/**
* Pure assembler: turns a rendered model plus an optional runtime snapshot into
* the facts payload. A null model means the render failed (renderError carries a
* redacted reason); a null snapshot means the runtime is unavailable, so drift
* is left empty rather than computed against an empty snapshot.
*/
export function assembleStackNetworkFacts(
stackName: string,
model: EffectiveModel | null,
renderError: string | null,
snapshot: DependencySnapshot | null,
): StackNetworkFacts {
const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable';
if (!model) {
return { stack: stackName, renderable: false, renderError, runtime, networks: [], services: [], drift: EMPTY_DRIFT };
}
const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({
key,
name: res.name,
external: res.external,
internal: res.internal,
createdByStack: !res.external && key !== 'default',
}));
const services: NetworkFactService[] = model.services.map(s => ({
name: s.name,
networks: s.networks.map(n => ({ key: n.key, aliases: n.aliases })),
publishedPorts: s.ports.map(p => ({
hostIp: p.hostIp,
startPort: p.startPort,
endPort: p.endPort,
protocol: p.protocol,
allInterfaces: isAllInterfaces(p.hostIp),
loopbackOnly: isLoopback(p.hostIp),
})),
networkMode: s.networkMode,
extraHosts: s.extraHosts,
}));
const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT;
return { stack: stackName, renderable: true, renderError: null, runtime, networks, services, drift };
}
/** Render the effective model and snapshot the node, then assemble the facts. */
export async function buildStackNetworkFacts(nodeId: number, stackName: string): Promise<StackNetworkFacts> {
const fsSvc = FileSystemService.getInstance(nodeId);
let model: EffectiveModel | null = null;
let renderError: string | null = null;
try {
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
if (result.rendered !== null) {
try {
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
} catch (parseErr) {
// JSON.parse errors carry no file content, so the message is safe to log.
console.warn('[NetworkInspector] Effective model parse failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
renderError = 'Sencho could not parse the rendered Compose model.';
}
} else {
// Raw stderr can echo file content/secrets and is never surfaced; only the
// names of any missing required variables, otherwise a generic nudge.
const missing = parseMissingRequiredVars(result.stderr);
renderError = missing.length
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
}
} catch (err) {
// Spawn failure (docker unavailable). Redact defensively.
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|| 'Sencho could not run docker compose on this node.';
}
// A null snapshot means the runtime is unavailable (drift is then left empty),
// never confused with a real empty snapshot.
let snapshot: DependencySnapshot | null = null;
try {
const knownStacks = await fsSvc.getStacks();
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
} catch (error) {
console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
}
return assembleStackNetworkFacts(stackName, model, renderError, snapshot);
}
@@ -0,0 +1,90 @@
/**
* A cheap per-node networking summary for the Fleet view filter: which stacks
* are exposed beyond loopback, which publish ports without an exposure intent,
* and which have network drift. It reads each stack's compose with the light
* dependency parser (no `docker compose config` render) and one Docker snapshot,
* so it stays inexpensive across a node's full stack set.
*/
import DockerController, { type DependencySnapshot } from '../DockerController';
import { FileSystemService } from '../FileSystemService';
import { DatabaseService } from '../DatabaseService';
import { parseComposeDependencies } from '../../helpers/composeDependencyParse';
import { assembleStackDrift } from '../DriftDetectionService';
import { isLoopback } from './normalize';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
/** One signal bucket: how many stacks match, and which. */
export interface NetworkingSummaryBucket {
count: number;
stacks: string[];
}
export interface NodeNetworkingSummary {
/** Stacks that publish a host port on a non-loopback interface. */
exposed: NetworkingSummaryBucket;
/** Stacks that publish ports but have no stack-level exposure intent set. */
unknownExposure: NetworkingSummaryBucket;
/** Stacks whose running networking disagrees with the Compose file. */
networkDrift: NetworkingSummaryBucket;
}
const bucket = (stacks: string[]): NetworkingSummaryBucket => ({ count: stacks.length, stacks });
export async function computeNodeNetworkingSummary(nodeId: number): Promise<NodeNetworkingSummary> {
const fsSvc = FileSystemService.getInstance(nodeId);
const db = DatabaseService.getInstance();
const stacks = await fsSvc.getStacks();
// One snapshot for the whole node; absent when Docker is unreachable, in which
// case drift is simply not computed (the declared signals still work).
let snapshot: DependencySnapshot | null = null;
try {
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
} catch (error) {
console.warn('[NetworkingSummary] Snapshot unavailable on node %d; drift skipped:',
nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
}
const exposed: string[] = [];
const unknownExposure: string[] = [];
const networkDrift: string[] = [];
for (const stack of stacks) {
let content: string;
try {
content = await fsSvc.getStackContent(stack);
} catch {
continue; // unreadable compose: nothing to summarize for this stack
}
const declared = parseComposeDependencies(content);
if (declared.parseError) continue;
const publishesPort = declared.services.some(s => s.ports.length > 0);
if (declared.services.some(s => s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack);
if (publishesPort) {
// Unknown only when a publishing service is effectively unclassified: a
// service-level intent overrides the stack-level row for that service.
const intents = db.getStackExposureIntents(nodeId, stack);
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent]));
const anyUnclassified = declared.services
.filter(s => s.ports.length > 0)
.some(s => {
const intent = byService.get(s.name) ?? stackIntent;
return intent === null || intent === 'unknown';
});
if (anyUnclassified) unknownExposure.push(stack);
}
if (snapshot) {
// declared.parseError is already excluded above, so the drift report is authoritative.
const containers = snapshot.containers.filter(c => c.stack === stack);
const report = assembleStackDrift({ stack, declared, containers, networks: snapshot.networks });
if (report.findings.some(f => f.kind === 'network-undeclared' || f.kind === 'network-missing')) networkDrift.push(stack);
}
}
return { exposed: bucket(exposed), unknownExposure: bucket(unknownExposure), networkDrift: bucket(networkDrift) };
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Backend-local networking helpers and the normalized network model that lets
* the Inspector (rendered EffectiveModel) and Drift (raw DeclaredCompose) feed
* one comparison. The rendered model already resolves resource names; the raw
* declared model does not, so each shape gets its own adapter and both emit the
* same key-space NormalizedNetworkModel. Do not import the frontend's access-url
* parser here; this is the backend copy.
*/
import type { EffectiveModel } from '../preflight/effectiveModel';
import type { DeclaredCompose } from '../../helpers/composeDependencyParse';
import type { DependencySnapshot } from '../DockerController';
import type { NetworkDriftFacts } from './types';
/** Container states that count as "deployed" for drift, matching DriftDetectionService. */
const RUNNING_STATES = new Set(['running', 'restarting']);
const SYSTEM_NETWORK_NAMES = new Set(['bridge', 'host', 'none']);
export function isAllInterfaces(ip: string): boolean {
return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]';
}
export function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '[::1]';
}
/** Resolved runtime name of a top-level network/volume: a `name:` override wins,
* otherwise compose prefixes the project (`<project>_<key>`). */
export function runtimeResourceName(projectName: string, key: string, declaredName: string | undefined): string {
return declaredName && declaredName !== key ? declaredName : `${projectName}_${key}`;
}
/** Extract host port numbers referenced by free-text access URLs, for the
* port-vs-documented finding. Heuristic: matches `:PORT` boundaries. */
export function parseAccessUrlPorts(text: string): Set<number> {
const ports = new Set<number>();
const re = /:(\d{1,5})(?=[/\s)\];]|$)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const p = parseInt(m[1], 10);
if (p > 0 && p <= 65535) ports.add(p);
}
return ports;
}
/** Key-space network model both adapters emit, so the comparison is shape-agnostic. */
export interface NormalizedNetworkModel {
projectName: string;
/** By network key → resolved runtime name + external flag. */
networks: Record<string, { runtimeName: string; external: boolean }>;
services: { name: string; networkKeys: string[]; networkMode?: string }[];
}
/** Rendered model: resource names are already resolved by `docker compose config`. */
export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel {
const networks: NormalizedNetworkModel['networks'] = {};
for (const [key, res] of Object.entries(m.networks)) {
networks[key] = { runtimeName: res.name, external: res.external };
}
return {
projectName: m.projectName,
networks,
services: m.services.map(s => ({ name: s.name, networkKeys: s.networks.map(n => n.key), networkMode: s.networkMode })),
};
}
/** Raw declared model: resolve runtime names here (project prefix / `name:` override). */
export function fromDeclaredCompose(m: DeclaredCompose, projectName: string): NormalizedNetworkModel {
const networks: NormalizedNetworkModel['networks'] = {};
for (const [key, res] of Object.entries(m.networks)) {
networks[key] = { runtimeName: runtimeResourceName(projectName, key, res.name), external: res.external };
}
return {
projectName,
networks,
services: m.services.map(s => ({ name: s.name, networkKeys: s.networks })),
};
}
/**
* Compare declared networks against the live snapshot. Only running/restarting
* containers of this stack count; system networks (bridge/host/none), the
* implicit default network, and external (shared) networks are not flagged.
*/
export function compareStackNetworks(
declared: NormalizedNetworkModel,
snapshot: DependencySnapshot,
stackName: string,
): NetworkDriftFacts {
const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = [];
const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = [];
// Every declared network (external included) resolves into this set, so an
// attachment to a declared external/shared network is treated as declared
// below, not as foreign.
const declaredRuntimeNames = new Set<string>();
for (const net of Object.values(declared.networks)) declaredRuntimeNames.add(net.runtimeName);
// The implicit default network compose always provisions counts as declared.
declaredRuntimeNames.add(`${declared.projectName}_default`);
const networkByName = new Map(snapshot.networks.map(n => [n.name, n]));
const stackContainers = snapshot.containers.filter(c => c.stack === stackName && RUNNING_STATES.has(c.state));
const usedRuntimeNames = new Set<string>();
for (const c of stackContainers) {
for (const attached of c.networks) {
const net = networkByName.get(attached.name);
if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue;
if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; }
if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) {
runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name });
} else {
foreignNetworkAttachments.push({ container: c.name, network: attached.name });
}
}
}
const runtimeNetworkNames = new Set(snapshot.networks.map(n => n.name));
const declaredButUnused: string[] = [];
const missingFromRuntime: string[] = [];
for (const [key, net] of Object.entries(declared.networks)) {
if (net.external || key === 'default') continue;
if (!runtimeNetworkNames.has(net.runtimeName)) { missingFromRuntime.push(net.runtimeName); continue; }
if (!usedRuntimeNames.has(net.runtimeName)) declaredButUnused.push(key);
}
return { runtimeOnlyAttachments, declaredButUnused, missingFromRuntime, foreignNetworkAttachments };
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Types for the Compose Network Inspector: the structural networking facts a
* Community user can read for a stack, derived from the rendered effective model
* and the live Docker snapshot. Like the preflight model, these facts never
* carry an environment value or a label value.
*/
/** Valid exposure-intent values (stored separately from these facts). The
* union is derived from this array so the two cannot drift; the frontend
* mirrors the same set. */
export const EXPOSURE_INTENTS = [
'internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown',
] as const;
export type ExposureIntent = typeof EXPOSURE_INTENTS[number];
/** A top-level network declared by the stack's effective model. */
export interface NetworkFactNetwork {
/** Compose network key. */
key: string;
/** Resolved docker network name. */
name: string;
external: boolean;
internal: boolean;
/** True when deploying this stack creates the network (not external, not the implicit default). */
createdByStack: boolean;
}
/** A host-published port of a service. */
export interface NetworkFactPort {
/** '' / '0.0.0.0' / '::' means all interfaces. */
hostIp: string;
startPort: number;
endPort: number;
protocol: string;
/** Bound on every interface (broad exposure). */
allInterfaces: boolean;
/** Bound only to loopback (127.0.0.1 / ::1). */
loopbackOnly: boolean;
}
/** One service's networking facts. */
export interface NetworkFactService {
name: string;
/** Network membership by network key, with aliases. */
networks: { key: string; aliases: string[] }[];
publishedPorts: NetworkFactPort[];
networkMode?: string;
extraHosts: string[];
}
/** Runtime-vs-Compose disagreements, computed only when the runtime is available. */
export interface NetworkDriftFacts {
/** Running container attached to a stack-owned network not declared in Compose. */
runtimeOnlyAttachments: { container: string; service: string | null; network: string }[];
/** Declared network (non-external, non-default) that no running service uses. */
declaredButUnused: string[];
/** Declared network whose runtime network does not exist. */
missingFromRuntime: string[];
/** Running container attached to a network owned by another stack or unmanaged. */
foreignNetworkAttachments: { container: string; network: string }[];
}
export type NetworkRuntimeState = 'available' | 'unavailable';
/**
* The full per-stack networking facts payload returned by GET /:stackName/networking.
* Kept a flat DTO (the frontend mirrors it) rather than a discriminated union;
* the pairing invariants are enforced by the single producer
* (`assembleStackNetworkFacts`), not by the type: when `renderable` is false,
* `renderError` is set and `networks`/`services`/`drift` are empty; when
* `runtime` is `'unavailable'`, `drift` is empty (never computed against an
* absent snapshot).
*/
export interface StackNetworkFacts {
stack: string;
/** True when the effective model rendered; false carries only a redacted, structural error. */
renderable: boolean;
/** Redacted render error when not renderable; never raw docker stderr. */
renderError: string | null;
/** Whether the live Docker snapshot was available; drift is computed only when 'available'. */
runtime: NetworkRuntimeState;
networks: NetworkFactNetwork[];
services: NetworkFactService[];
drift: NetworkDriftFacts;
}
@@ -21,6 +21,14 @@ export interface EffBind {
target: string;
}
/** A service's membership in one top-level network, keyed by the network KEY
* (not the resolved docker name) so it lines up with the `networks` map and
* with the authored `DeclaredService.networks`. */
export interface EffServiceNetwork {
key: string;
aliases: string[];
}
export interface EffService {
name: string;
image?: string;
@@ -37,12 +45,20 @@ export interface EffService {
user?: string;
/** Environment KEY names only. Values are never extracted. */
envKeys: string[];
/** Network membership by network key, with any aliases. */
networks: EffServiceNetwork[];
/** `extra_hosts` entries as `host:value` strings (host names / static IPs, never secrets). */
extraHosts: string[];
/** Label KEY names only. Values are never extracted (a label value can carry a secret). */
labelKeys: string[];
}
export interface EffResource {
/** Resolved docker name (compose config fills this in). */
name: string;
external: boolean;
/** Top-level `internal: true` (no outbound/host connectivity for the network). */
internal: boolean;
}
export interface EffectiveModel {
@@ -132,12 +148,56 @@ function envKeysOf(env: unknown): string[] {
return [];
}
/** Label KEY names only. A label VALUE can carry a secret, so it is never read. */
function labelKeysOf(labels: unknown): string[] {
if (Array.isArray(labels)) {
return labels
.map(e => str(e))
.filter((s): s is string => s !== undefined)
.map(s => s.split('=')[0])
.filter(Boolean);
}
if (labels && typeof labels === 'object') return Object.keys(labels as Record<string, unknown>);
return [];
}
/** Service network membership (list or map form), keyed by network key, with aliases. */
function parseServiceNetworks(networks: unknown): EffServiceNetwork[] {
if (Array.isArray(networks)) {
return networks
.map(n => str(n))
.filter((s): s is string => s !== undefined)
.map(key => ({ key, aliases: [] as string[] }));
}
if (networks && typeof networks === 'object') {
return Object.entries(networks as Record<string, unknown>).map(([key, cfg]) => {
const aliasesRaw = (cfg && typeof cfg === 'object') ? (cfg as Record<string, unknown>).aliases : undefined;
const aliases = Array.isArray(aliasesRaw)
? aliasesRaw.map(a => str(a)).filter((a): a is string => a !== undefined)
: [];
return { key, aliases };
});
}
return [];
}
/** `extra_hosts` (list `host:ip` or map `{host: ip}`) → `host:value` strings. Infra facts, not secrets. */
function parseExtraHosts(extraHosts: unknown): string[] {
if (Array.isArray(extraHosts)) {
return extraHosts.map(e => str(e)).filter((s): s is string => s !== undefined);
}
if (extraHosts && typeof extraHosts === 'object') {
return Object.entries(extraHosts as Record<string, unknown>).map(([host, ip]) => `${host}:${str(ip) ?? ''}`);
}
return [];
}
function parseResources(value: unknown): Record<string, EffResource> {
const out: Record<string, EffResource> = {};
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const o = (entry ?? {}) as Record<string, unknown>;
out[key] = { name: str(o.name) ?? key, external: o.external === true };
out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true };
}
}
return out;
@@ -177,6 +237,9 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
containerName: str(svc.container_name),
user: str(svc.user),
envKeys: envKeysOf(svc.environment),
networks: parseServiceNetworks(svc.networks),
extraHosts: parseExtraHosts(svc.extra_hosts),
labelKeys: labelKeysOf(svc.labels),
});
}
+151
View File
@@ -1,5 +1,7 @@
import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
import type { EffService, EffPortSpec } from './effectiveModel';
import type { ExposureIntent } from '../network/types';
import { isLoopback } from '../network/normalize';
/** Higher number = more severe. Used to derive a run's overall status. */
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
@@ -521,6 +523,150 @@ const effectiveModelExpanded: PreflightRule = {
},
};
// ----- exposure-intent rules ------------------------------------------------
// These read the user's stored exposure classification (resolved per service)
// and the dossier's documented access URLs from the context, plus a sensitivity
// heuristic on the image name for the broad-exposure rule.
/** Image-name hints for a database or admin service that should rarely be broadly exposed. */
const SENSITIVE_IMAGE_HINTS = [
'postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'memcached', 'elasticsearch',
'adminer', 'phpmyadmin', 'portainer', 'docker-socket-proxy',
];
/** Reverse-proxy label-key base names; matched as the key itself or a `base.` prefix (case-insensitive). */
const REVERSE_PROXY_LABEL_HINTS = ['traefik', 'caddy', 'virtual.host'];
/** A service's effective intent: its own override, else the stack-level intent. */
function effectiveIntent(ctx: PreflightContext, service: string): ExposureIntent | null {
return ctx.serviceIntents[service] ?? ctx.stackIntent;
}
const exposureInternalPublished: PreflightRule = {
id: 'exposure-internal-published',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
const intent = effectiveIntent(ctx, svc.name);
if (intent !== 'internal' && intent !== 'same-node') continue;
// same-node tolerates a loopback binding; internal tolerates no host port.
const offending = svc.ports.filter(p => intent === 'internal' || !isLoopback(p.hostIp));
if (offending.length === 0) continue;
findings.push({
ruleId: 'exposure-internal-published',
severity: 'high',
title: `"${svc.name}" is classified ${intent} but publishes a host port`,
message: `Service "${svc.name}" is classified as ${intent} exposure, but it publishes ${offending.map(specLabel).join(', ')} to the host, which contradicts that intent.`,
sourcePath: svc.name,
service: svc.name,
remediation: intent === 'same-node'
? 'Bind the port to loopback (127.0.0.1), remove it, or reclassify the exposure intent.'
: 'Remove the published port or reclassify the exposure intent.',
});
}
return findings;
},
};
const exposureUnclassified: PreflightRule = {
id: 'exposure-unclassified',
run(ctx) {
if (!ctx.model) return [];
const publishing = ctx.model.services.filter(s => s.ports.length > 0);
if (publishing.length === 0) return [];
// Fire only when a publishing service is still effectively unclassified: a
// service-level intent suppresses the warning for that service even when the
// stack itself is unset.
const unclassified = publishing.some(s => {
const intent = effectiveIntent(ctx, s.name);
return intent === null || intent === 'unknown';
});
if (!unclassified) return [];
return [{
ruleId: 'exposure-unclassified',
severity: 'warning',
title: 'Stack publishes ports without an exposure intent',
message: 'This stack publishes one or more host ports but has no exposure intent set. Classifying it (internal, LAN, reverse proxy, public) lets Sencho flag mismatches later.',
remediation: 'Set the stack exposure intent in the Networking tab.',
}];
},
};
const exposurePortVsDossier: PreflightRule = {
id: 'exposure-port-vs-dossier',
run(ctx) {
if (!ctx.model || !ctx.hasAccessUrls) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
const undocumented = [...new Set(svc.ports.flatMap(portsOf).filter(p => !ctx.accessUrlPorts.has(p)))];
if (undocumented.length === 0) continue;
findings.push({
ruleId: 'exposure-port-vs-dossier',
severity: 'warning',
title: 'Published port is not in the documented access URLs',
message: `Service "${svc.name}" publishes ${undocumented.join(', ')}, which ${undocumented.length > 1 ? 'are' : 'is'} not referenced by the dossier's documented access URLs. The documentation may be stale.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Update the access URLs in the Stack Dossier, or change the published port.',
});
}
return findings;
},
};
const reverseProxyUndocumented: PreflightRule = {
id: 'reverse-proxy-undocumented',
run(ctx) {
if (!ctx.model) return [];
// Already documented or intentionally reverse-proxied at the stack level.
if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
if (effectiveIntent(ctx, svc.name) === 'reverse-proxy') continue;
const hasRpLabel = svc.labelKeys.some(k => {
const lk = k.toLowerCase();
return REVERSE_PROXY_LABEL_HINTS.some(h => lk === h || lk.startsWith(`${h}.`));
});
if (!hasRpLabel) continue;
findings.push({
ruleId: 'reverse-proxy-undocumented',
severity: 'warning',
title: `"${svc.name}" has reverse-proxy labels but no documented URL`,
message: `Service "${svc.name}" carries reverse-proxy labels, but the stack has no documented access URL or reverse-proxy intent, so how it is reached is unclear.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Document the access URL in the Stack Dossier or set the exposure intent to reverse proxy.',
});
}
return findings;
},
};
const sensitiveServiceBroadExposure: PreflightRule = {
id: 'sensitive-service-broad-exposure',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
if (svc.image === undefined) continue;
const image = svc.image.toLowerCase();
if (!SENSITIVE_IMAGE_HINTS.some(h => image.includes(h))) continue;
const broad = svc.ports.filter(p => isAllInterfaces(p.hostIp));
if (broad.length === 0) continue;
findings.push({
ruleId: 'sensitive-service-broad-exposure',
severity: 'high',
title: `Sensitive service "${svc.name}" is exposed on all interfaces`,
message: `Service "${svc.name}" looks like a database or admin service (${svc.image}) and publishes ${broad.map(specLabel).join(', ')} on all interfaces. Broadly exposing it is a common source of compromise.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Bind it to a specific interface such as 127.0.0.1, or keep it on an internal network only.',
});
}
return findings;
},
};
/** The ordered registry. Order is the display order within a severity group. */
export const PREFLIGHT_RULES: PreflightRule[] = [
renderFailed,
@@ -544,6 +690,11 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
newVolume,
containerNameInternalDup,
containerNameCollision,
exposureInternalPublished,
sensitiveServiceBroadExposure,
exposureUnclassified,
exposurePortVsDossier,
reverseProxyUndocumented,
effectiveModelExpanded,
];
+9
View File
@@ -1,4 +1,5 @@
import type { EffectiveModel } from './effectiveModel';
import type { ExposureIntent } from '../network/types';
/** Graded severity of a single preflight finding. */
export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
@@ -92,4 +93,12 @@ export interface PreflightContext {
existingVolumeNames: Set<string>;
existingContainers: { name: string; stack: string | null }[];
bindChecks: BindCheck[];
/** Stack-level exposure classification, or null when unset. */
stackIntent: ExposureIntent | null;
/** Per-service exposure overrides (a service falls back to stackIntent when absent). */
serviceIntents: Record<string, ExposureIntent>;
/** Host ports referenced by the dossier's documented access URLs. */
accessUrlPorts: Set<number>;
/** Whether the dossier records any access URL (gates the port-vs-documented rule). */
hasAccessUrls: boolean;
}