diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 0c7950e2..aa2443f6 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -958,7 +958,18 @@ describe('ComposeService - idle-output stall backstop', () => { process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000'; mockListContainers.mockResolvedValue([]); const proc = createMockProcess(); - mockSpawn.mockReturnValue(proc); + // deployStack now spawns a second child (exposure refresh via renderConfig) + // after the up command closes. Return the controlled proc for the up spawn, + // and a fresh auto-closing proc for the config spawn so the test does not + // hang on the already-closed proc. + let spawnCount = 0; + mockSpawn.mockImplementation(() => { + spawnCount += 1; + if (spawnCount === 1) return proc; + const configProc = createMockProcess(); + Promise.resolve().then(() => configProc.emit('close', 0)); + return configProc; + }); const svc = ComposeService.getInstance(1); // deployStack spawns a single `up`; emit output every 600ms (< 1s window) @@ -997,7 +1008,16 @@ describe('ComposeService - idle-output stall backstop', () => { process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '0'; // invalid → default (10min) mockListContainers.mockResolvedValue([]); const proc = createMockProcess(); - mockSpawn.mockReturnValue(proc); + // Same pattern as the stall test above: the second spawn (exposure refresh) + // needs its own auto-closing proc so deployStack can resolve after close. + let spawnCount = 0; + mockSpawn.mockImplementation(() => { + spawnCount += 1; + if (spawnCount === 1) return proc; + const configProc = createMockProcess(); + Promise.resolve().then(() => configProc.emit('close', 0)); + return configProc; + }); const svc = ComposeService.getInstance(1); const promise = svc.deployStack('my-stack'); diff --git a/backend/src/__tests__/exposure.test.ts b/backend/src/__tests__/exposure.test.ts new file mode 100644 index 00000000..132e1406 --- /dev/null +++ b/backend/src/__tests__/exposure.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest'; +import { deriveStackExposure, buildExposedImageMap, type StackExposure } from '../services/preflight/exposure'; +import type { EffectiveModel } from '../services/preflight/effectiveModel'; + +function svc(overrides: Record) { + return { + name: 'app', + image: 'nginx:latest', + ports: [] as Array<{ startPort: number; endPort: number; hostIp: string; protocol: string }>, + binds: [], + namedVolumes: [], + storageMounts: [], + privileged: false, + networkMode: undefined as string | undefined, + restart: undefined as string | undefined, + hasHealthcheck: false, + envKeys: [], + networks: [], + extraHosts: [], + labelKeys: [], + ...overrides, + }; +} + +function model(overrides: Partial): EffectiveModel { + return { + projectName: 'test', + services: [], + networks: {}, + volumes: {}, + ...overrides, + }; +} + +const NOW = 1700000000000; + +describe('deriveStackExposure', () => { + it('marks a service with no ports and no host networking as not exposed', () => { + const m = model({ services: [svc({ image: 'nginx:latest' })] }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(false); + expect(r.services[0].reason).toBeNull(); + }); + + it('marks a service publishing on 0.0.0.0 as exposed', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); + expect(r.services[0].reason).toBe('published-port'); + expect(r.services[0].bindings).toEqual(['0.0.0.0:8080/tcp']); + }); + + it('marks a service publishing on :: (IPv6 all-interfaces) as exposed', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 3000, endPort: 3000, hostIp: '::', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); + }); + + it('marks a service publishing on an empty host IP as exposed (Docker default = all interfaces)', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 5432, endPort: 5432, hostIp: '', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); + }); + + it('marks a service publishing on a specific LAN IP as exposed', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 8080, endPort: 8080, hostIp: '192.168.1.50', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); + }); + + it('keeps a loopback-only service as not exposed', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.1', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(false); + }); + + it('marks ::1 (IPv6 loopback) as not exposed', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 8080, endPort: 8080, hostIp: '::1', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(false); + }); + + it('marks any 127.0.0.0/8 address as loopback (not exposed)', () => { + const m = model({ + services: [ + svc({ + ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.2', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(false); + }); + + it('marks a host-network service as exposed even with no published ports', () => { + const m = model({ + services: [ + svc({ networkMode: 'host' }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); + expect(r.services[0].reason).toBe('host-network'); + expect(r.services[0].bindings).toEqual([]); + }); + + it('does not mark network_mode: none as exposed', () => { + const m = model({ + services: [ + svc({ networkMode: 'none' }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(false); + }); + + it('carries the image reference through for downstream joins', () => { + const m = model({ + services: [ + svc({ + image: 'postgres:15', + ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].image).toBe('postgres:15'); + }); + + it('sets image to null for build-only services', () => { + const m = model({ + services: [ + svc({ + image: undefined, + ports: [{ startPort: 3000, endPort: 3000, hostIp: '0.0.0.0', protocol: 'tcp' }], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].image).toBeNull(); + expect(r.services[0].publiclyExposed).toBe(true); // still exposed via port + }); + + it('handles multi-service stacks with mixed exposure', () => { + const m = model({ + services: [ + svc({ + name: 'frontend', + ports: [{ startPort: 80, endPort: 80, hostIp: '0.0.0.0', protocol: 'tcp' }], + }), + svc({ name: 'backend', ports: [{ startPort: 4000, endPort: 4000, hostIp: '127.0.0.1', protocol: 'tcp' }] }), + svc({ name: 'metrics', networkMode: 'host' }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].publiclyExposed).toBe(true); // frontend + expect(r.services[1].publiclyExposed).toBe(false); // backend (loopback) + expect(r.services[2].publiclyExposed).toBe(true); // metrics (host network) + }); + + it('includes the stack name and timestamp in the descriptor', () => { + const m = model({ services: [svc({})] }); + const r = deriveStackExposure(m, 'mystack', NOW); + expect(r.stack).toBe('mystack'); + expect(r.computedAt).toBe(NOW); + }); + + it('produces bindings in host-only format without container target ports', () => { + const m = model({ + services: [ + svc({ + ports: [ + { startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }, + { startPort: 9000, endPort: 9001, hostIp: '', protocol: 'udp' }, + ], + }), + ], + }); + const r = deriveStackExposure(m, 'test', NOW); + expect(r.services[0].bindings).toEqual([ + '0.0.0.0:8080/tcp', + '0.0.0.0:9000-9001/udp', + ]); + }); +}); + +describe('buildExposedImageMap', () => { + function exp(stack: string, services: Array<{ image: string | null; publiclyExposed: boolean }>): StackExposure { + return { + stack, + computedAt: NOW, + services: services.map((s) => ({ + service: 's', + image: s.image, + publiclyExposed: s.publiclyExposed, + reason: s.publiclyExposed ? 'published-port' : null, + bindings: [], + })), + }; + } + + it('returns an empty map for no exposures', () => { + expect(buildExposedImageMap([]).size).toBe(0); + }); + + it('maps an exposed image to true', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]), + ]); + expect(map.get('nginx:latest')).toBe(true); + }); + + it('maps an internal-only image to false', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]), + ]); + expect(map.get('nginx:latest')).toBe(false); + }); + + it('skips build-only services (no image)', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: null, publiclyExposed: true }]), + ]); + expect(map.has(null as unknown as string)).toBe(false); + expect(map.size).toBe(0); + }); + + it('true wins over false when the same image appears in multiple stacks', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]), + exp('b', [{ image: 'nginx:latest', publiclyExposed: true }]), + ]); + expect(map.get('nginx:latest')).toBe(true); + }); + + it('true stays true even when a later stack classifies the image internal', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]), + exp('b', [{ image: 'nginx:latest', publiclyExposed: false }]), + ]); + expect(map.get('nginx:latest')).toBe(true); + }); + + it('returns false when the image appears only as internal across all stacks', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'postgres:15', publiclyExposed: false }]), + exp('b', [{ image: 'postgres:15', publiclyExposed: false }]), + ]); + expect(map.get('postgres:15')).toBe(false); + }); + + it('leaves an absent image as undefined (no descriptor contains it)', () => { + const map = buildExposedImageMap([ + exp('a', [{ image: 'redis:7', publiclyExposed: true }]), + ]); + expect(map.get('nginx:latest')).toBeUndefined(); + }); + + it('handles mixed images in the same stack', () => { + const map = buildExposedImageMap([ + exp('a', [ + { image: 'frontend:1', publiclyExposed: true }, + { image: 'backend:1', publiclyExposed: false }, + ]), + ]); + expect(map.get('frontend:1')).toBe(true); + expect(map.get('backend:1')).toBe(false); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 9267e65c..7d6c6d39 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -15,6 +15,7 @@ import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter'; import { generateSarif } from '../services/SarifExporter'; import { generateOpenVex } from '../services/OpenVexExporter'; import { deriveSecurityPosture, type SecurityPostureFacts, type SecurityPostureState } from '../services/securityPosture'; +import { buildExposedImageMap } from '../services/preflight/exposure'; import { sanitizeForLog } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; @@ -555,11 +556,21 @@ securityRouter.get('/scans/:scanId', authMiddleware, (req: Request, res: Respons if (!Number.isFinite(scanId)) { res.status(400).json({ error: 'Invalid scan id' }); return; } - const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId); + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); if (!scan || scan.node_id !== req.nodeId) { res.status(404).json({ error: 'Scan not found' }); return; } - res.json(shapeScanForResponse(scan)); + // Attach the exposure status for the scan sheet badge (tri-state: + // true = public, false = internal, absent = no descriptor cached). + const exposures = db.getStackExposures(req.nodeId); + const exposedMap = buildExposedImageMap( + exposures.map((r) => { + try { return JSON.parse(r.descriptor); } catch { return null; } + }).filter(Boolean), + ); + const publiclyExposed = exposedMap.get(scan.image_ref) ?? null; + res.json({ ...shapeScanForResponse(scan), publicly_exposed: publiclyExposed }); }); securityRouter.get( @@ -752,8 +763,19 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v } } - // Compose exposure is joined in a later phase; until then it is honestly zero. - const publiclyExposed = 0; + // Count distinct images that are publicly exposed AND have at least one + // non-suppressed Critical/High finding. The exposure descriptor is cached + // at deploy/update time, so this is O(stacks) + O(images), zero subprocess. + const exposures = db.getStackExposures(req.nodeId); + const exposedMap = buildExposedImageMap( + exposures.map((r) => { + try { return JSON.parse(r.descriptor); } catch { return null; } + }).filter(Boolean), + ); + let publiclyExposed = 0; + for (const [imageRef] of critHighByImage) { + if (exposedMap.get(imageRef) === true) publiclyExposed += 1; + } const postureFacts: SecurityPostureFacts = { scannerAvailable: svc.isTrivyAvailable(), diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index b2a86b7d..ff3cfc7d 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -946,6 +946,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName); DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName); DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName); + DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); } catch (dbErr) { console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index ac4913c3..5667ff9a 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -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) { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 923a45a6..54a139f7 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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 { @@ -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 { + 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 { return new Promise((resolve, reject) => { const child = spawn('docker', ['compose', ...args], { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 79ace26a..e893b909 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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)); diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts index f1d7a796..b7a1daf6 100644 --- a/backend/src/services/network/normalize.ts +++ b/backend/src/services/network/normalize.ts @@ -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 diff --git a/backend/src/services/preflight/exposure.ts b/backend/src/services/preflight/exposure.ts new file mode 100644 index 00000000..1a0dbb3b --- /dev/null +++ b/backend/src/services/preflight/exposure.ts @@ -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 { + const map = new Map(); + 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; +} diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 142dec44..e01aafc3 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -528,9 +528,14 @@ export function VulnerabilityScanSheet({ } }, [scan]); - const meta = scan - ? `${scan.total_vulnerabilities} vulns · ${scan.fixable_count} fixable · ${scan.triggered_by}` - : (loading ? 'Loading…' : 'No scan'); + const meta = scan ? ( + + {scan.total_vulnerabilities} vulns · {scan.fixable_count} fixable · {scan.triggered_by} + {scan.publicly_exposed === true && ( + Published service + )} + + ) : (loading ? 'Loading…' : 'No scan'); const footerContext = scan ? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}` diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 3e8829ff..cde9f49a 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -70,6 +70,9 @@ export interface VulnerabilityScan { error: string | null; stack_context: string | null; policy_evaluation?: ScanPolicyEvaluation | null; + /** Tri-state Compose exposure for the scan-sheet badge: true = publicly + * exposed, false = internal only, null/absent = no descriptor cached. */ + publicly_exposed?: boolean | null; } export interface SecretFinding {