feat(security): surface Compose internet-reachability exposure in posture (#1442)

* feat(security): surface Compose internet-reachability exposure in posture

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

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

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

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

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

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

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

- Expand isLoopback to cover full 127.0.0.0/8 range (127.0.0.2 etc)
- Clarify that exposure is configured (Compose model), not live topology
- Remove "Internal only" badge: false is not proof of non-exposure when
  other stacks using the same image may lack a cached descriptor
This commit is contained in:
Anso
2026-06-24 23:22:13 -04:00
committed by GitHub
parent db8bb70b7d
commit 3a22f59057
11 changed files with 576 additions and 10 deletions
+7
View File
@@ -473,6 +473,13 @@ export class BlueprintService {
if (await this.stackDirExists(node.id, blueprint.name)) {
await FileSystemService.getInstance(node.id).deleteStack(blueprint.name);
}
// Remove the exposure descriptor so a withdrawn blueprint
// stack does not leave a stale row that escalates posture.
try {
DatabaseService.getInstance().deleteStackExposure(node.id, blueprint.name);
} catch (e) {
console.warn(`[BlueprintService] deleteStackExposure failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(e)}`);
}
},
);
if (!lock.ran) {
+46
View File
@@ -11,6 +11,8 @@ import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
import { DriftLedgerService } from './DriftLedgerService';
import { parseEffectiveModel } from './preflight/effectiveModel';
import { deriveStackExposure } from './preflight/exposure';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
@@ -481,6 +483,14 @@ export class ComposeService {
// instead restores the previous files and throws above, so that recovery path
// reconciles on its next deploy or scan, not here. Best-effort internally.
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
// Refresh the exposure cache so posture reflects the just-deployed model.
// Best-effort: a refresh failure logs a warning but never fails the deploy.
try {
await this.refreshExposureCache(stackName);
} catch (err) {
console.warn('[ComposeService] Exposure refresh failed after deploy for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
}
streamLogs(stackName: string, ws: WebSocket) {
@@ -682,6 +692,12 @@ export class ComposeService {
// reconcile the ledger against the updated runtime.
await DriftLedgerService.getInstance().recordBaseline(this.nodeId, stackName);
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
try {
await this.refreshExposureCache(stackName);
} catch (err) {
console.warn('[ComposeService] Exposure refresh failed after update for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
}
public async downStack(stackName: string): Promise<void> {
@@ -728,6 +744,36 @@ export class ComposeService {
return images;
}
/** Render the effective Compose model and cache the per-stack exposure
* descriptor so the Security posture can join exposed images against
* vulnerability findings without re-rendering config on every poll.
* Best-effort: render or parse failure logs a warning and keeps the
* prior cached descriptor, never failing the deploy. */
private async refreshExposureCache(stackName: string): Promise<void> {
const result = await this.renderConfig(stackName);
if (result.rendered === null) {
console.warn('[ComposeService] Exposure cache skipped for %s: model not renderable',
sanitizeForLog(stackName));
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(result.rendered);
} catch {
console.warn('[ComposeService] Exposure cache skipped for %s: unparseable model JSON',
sanitizeForLog(stackName));
return;
}
const model = parseEffectiveModel(parsed, stackName);
const descriptor = deriveStackExposure(model, stackName, Date.now());
DatabaseService.getInstance().upsertStackExposure(
this.nodeId,
stackName,
JSON.stringify(descriptor),
descriptor.computedAt,
);
}
private captureCompose(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('docker', ['compose', ...args], {
+54
View File
@@ -111,6 +111,15 @@ export interface PreflightRunRow {
created_by: string | null;
}
/** Per-stack per-service Compose exposure descriptor (one row per node+stack). */
export interface StackExposureRow {
node_id: number;
stack_name: string;
/** JSON StackExposure from preflight/exposure.ts. */
descriptor: string;
computed_at: number;
}
/** One post-update health gate observation run. */
export interface HealthGateRunRow {
id: string;
@@ -1398,6 +1407,14 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
ON preflight_findings(run_id);
CREATE TABLE IF NOT EXISTS stack_exposure (
node_id INTEGER NOT NULL DEFAULT 0,
stack_name TEXT NOT NULL,
descriptor TEXT NOT NULL,
computed_at INTEGER NOT NULL,
PRIMARY KEY (node_id, stack_name)
);
CREATE TABLE IF NOT EXISTS health_gate_runs (
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
@@ -2504,6 +2521,42 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Stack Exposure (Compose reachability descriptor) ---
/** Store a per-stack exposure descriptor, replacing any prior row. */
public upsertStackExposure(nodeId: number, stackName: string, descriptor: string, computedAt: number): void {
this.db.prepare(
`INSERT INTO stack_exposure (node_id, stack_name, descriptor, computed_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (node_id, stack_name) DO UPDATE SET
descriptor = excluded.descriptor,
computed_at = excluded.computed_at`
).run(nodeId, stackName, descriptor, computedAt);
}
/** Return every cached exposure descriptor for a node. Malformed rows are
* skipped (logged) so a single corrupt row cannot fail the overview. */
public getStackExposures(nodeId: number): StackExposureRow[] {
const rows = this.db.prepare(
'SELECT node_id, stack_name, descriptor, computed_at FROM stack_exposure WHERE node_id = ?'
).all(nodeId) as StackExposureRow[];
return rows.filter((r) => {
try {
JSON.parse(r.descriptor);
return true;
} catch {
console.warn('[DatabaseService] Dropping malformed stack_exposure row for node=%d stack=%s',
r.node_id, r.stack_name);
return false;
}
});
}
/** Remove the exposure row for a single stack. */
public deleteStackExposure(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_exposure 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). */
@@ -2939,6 +2992,7 @@ export class DatabaseService {
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 stack_exposure WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
+1 -1
View File
@@ -20,7 +20,7 @@ export function isAllInterfaces(ip: string): boolean {
}
export function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '[::1]';
return ip.startsWith('127.') || ip === '::1' || ip === '[::1]';
}
/** True for `network_mode: host`, which publishes every container port directly
+103
View File
@@ -0,0 +1,103 @@
/**
* Per-stack/per-service Compose exposure descriptor. Reuses the existing
* effective-model parser and the normalize helpers; does not reimplement
* port/bind detection.
*
* Exposure represents CONFIGURED reachability as declared in the Compose
* model, refreshed on deploy/update. It is NOT live topology: down/stop
* do not clear the cache, just as vulnerability scan data persists after
* containers stop. The descriptor reflects what the compose file declares,
* not what is currently running.
*
* The signal is tri-state per image: true (publicly exposed), false
* (internal only in every cached stack containing the image), or absent
* (no cached descriptor). It is an escalation input for the Security
* posture, never an auto-suppression.
*/
import type { EffectiveModel } from './effectiveModel';
import { isLoopback, isHostNetwork } from '../network/normalize';
export interface ServiceExposure {
service: string;
/** Join key to vulnerability_scans.image_ref. Absent for build-only services. */
image: string | null;
publiclyExposed: boolean;
reason: 'published-port' | 'host-network' | null;
/** Host-side binding strings, e.g. "0.0.0.0:8080/tcp". */
bindings: string[];
}
export interface StackExposure {
stack: string;
services: ServiceExposure[];
computedAt: number;
}
/** Build a port-range label: "8080" for a single port, "8080-8090" for a range. */
function portLabel(startPort: number, endPort: number): string {
return startPort === endPort ? `${startPort}` : `${startPort}-${endPort}`;
}
/**
* Derive a per-stack exposure descriptor from the rendered effective model.
* Pure function with no side effects; callers own caching and persistence.
*/
export function deriveStackExposure(
model: EffectiveModel,
stackName: string,
now: number,
): StackExposure {
const services: ServiceExposure[] = model.services.map((svc) => {
// Publicly exposed when any published port binds to a non-loopback address,
// or when network_mode is host (every container port is published on the host).
const nonLoopbackPorts = svc.ports.filter((p) => !isLoopback(p.hostIp));
const hostNetwork = isHostNetwork(svc.networkMode);
const publiclyExposed = nonLoopbackPorts.length > 0 || hostNetwork;
const bindings = nonLoopbackPorts.map(
(p) => `${p.hostIp || '0.0.0.0'}:${portLabel(p.startPort, p.endPort)}/${p.protocol}`,
);
return {
service: svc.name,
image: svc.image ?? null,
publiclyExposed,
reason: hostNetwork
? 'host-network'
: nonLoopbackPorts.length > 0
? 'published-port'
: null,
bindings,
};
});
return { stack: stackName, services, computedAt: now };
}
/**
* Build a per-node image->exposed tri-state map from all cached stack
* descriptors. The map answers:
* true = at least one service using this image is publicly exposed
* false = every cached descriptor containing this image marks it internal-only
* absent = no cached descriptor contains this image (null)
*
* When multiple stacks contain the same image, one public exposure wins over
* any number of internal-only classifications (conservative escalation).
*/
export function buildExposedImageMap(
exposures: StackExposure[],
): Map<string, boolean> {
const map = new Map<string, boolean>();
for (const exp of exposures) {
for (const svc of exp.services) {
if (!svc.image) continue; // build-only services have no join key
const current = map.get(svc.image);
// true wins: once an image is known to be publicly exposed anywhere,
// it stays true regardless of other stacks classifying it internal.
if (current === true) continue;
map.set(svc.image, svc.publiclyExposed);
}
}
return map;
}