mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
return {
|
||||
name: 'app',
|
||||
image: 'nginx:latest',
|
||||
ports: [] as Array<{ startPort: number; endPort: number; hostIp: string; protocol: string }>,
|
||||
binds: [],
|
||||
namedVolumes: [],
|
||||
storageMounts: [],
|
||||
privileged: false,
|
||||
networkMode: undefined as string | undefined,
|
||||
restart: undefined as string | undefined,
|
||||
hasHealthcheck: false,
|
||||
envKeys: [],
|
||||
networks: [],
|
||||
extraHosts: [],
|
||||
labelKeys: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function model(overrides: Partial<EffectiveModel>): EffectiveModel {
|
||||
return {
|
||||
projectName: 'test',
|
||||
services: [],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const NOW = 1700000000000;
|
||||
|
||||
describe('deriveStackExposure', () => {
|
||||
it('marks a service with no ports and no host networking as not exposed', () => {
|
||||
const m = model({ services: [svc({ image: 'nginx:latest' })] });
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(false);
|
||||
expect(r.services[0].reason).toBeNull();
|
||||
});
|
||||
|
||||
it('marks a service publishing on 0.0.0.0 as exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true);
|
||||
expect(r.services[0].reason).toBe('published-port');
|
||||
expect(r.services[0].bindings).toEqual(['0.0.0.0:8080/tcp']);
|
||||
});
|
||||
|
||||
it('marks a service publishing on :: (IPv6 all-interfaces) as exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 3000, endPort: 3000, hostIp: '::', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a service publishing on an empty host IP as exposed (Docker default = all interfaces)', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 5432, endPort: 5432, hostIp: '', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a service publishing on a specific LAN IP as exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 8080, endPort: 8080, hostIp: '192.168.1.50', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a loopback-only service as not exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.1', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks ::1 (IPv6 loopback) as not exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 8080, endPort: 8080, hostIp: '::1', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks any 127.0.0.0/8 address as loopback (not exposed)', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.2', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks a host-network service as exposed even with no published ports', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({ networkMode: 'host' }),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true);
|
||||
expect(r.services[0].reason).toBe('host-network');
|
||||
expect(r.services[0].bindings).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mark network_mode: none as exposed', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({ networkMode: 'none' }),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the image reference through for downstream joins', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
image: 'postgres:15',
|
||||
ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].image).toBe('postgres:15');
|
||||
});
|
||||
|
||||
it('sets image to null for build-only services', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
image: undefined,
|
||||
ports: [{ startPort: 3000, endPort: 3000, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].image).toBeNull();
|
||||
expect(r.services[0].publiclyExposed).toBe(true); // still exposed via port
|
||||
});
|
||||
|
||||
it('handles multi-service stacks with mixed exposure', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
name: 'frontend',
|
||||
ports: [{ startPort: 80, endPort: 80, hostIp: '0.0.0.0', protocol: 'tcp' }],
|
||||
}),
|
||||
svc({ name: 'backend', ports: [{ startPort: 4000, endPort: 4000, hostIp: '127.0.0.1', protocol: 'tcp' }] }),
|
||||
svc({ name: 'metrics', networkMode: 'host' }),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].publiclyExposed).toBe(true); // frontend
|
||||
expect(r.services[1].publiclyExposed).toBe(false); // backend (loopback)
|
||||
expect(r.services[2].publiclyExposed).toBe(true); // metrics (host network)
|
||||
});
|
||||
|
||||
it('includes the stack name and timestamp in the descriptor', () => {
|
||||
const m = model({ services: [svc({})] });
|
||||
const r = deriveStackExposure(m, 'mystack', NOW);
|
||||
expect(r.stack).toBe('mystack');
|
||||
expect(r.computedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('produces bindings in host-only format without container target ports', () => {
|
||||
const m = model({
|
||||
services: [
|
||||
svc({
|
||||
ports: [
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
||||
{ startPort: 9000, endPort: 9001, hostIp: '', protocol: 'udp' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const r = deriveStackExposure(m, 'test', NOW);
|
||||
expect(r.services[0].bindings).toEqual([
|
||||
'0.0.0.0:8080/tcp',
|
||||
'0.0.0.0:9000-9001/udp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExposedImageMap', () => {
|
||||
function exp(stack: string, services: Array<{ image: string | null; publiclyExposed: boolean }>): StackExposure {
|
||||
return {
|
||||
stack,
|
||||
computedAt: NOW,
|
||||
services: services.map((s) => ({
|
||||
service: 's',
|
||||
image: s.image,
|
||||
publiclyExposed: s.publiclyExposed,
|
||||
reason: s.publiclyExposed ? 'published-port' : null,
|
||||
bindings: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns an empty map for no exposures', () => {
|
||||
expect(buildExposedImageMap([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps an exposed image to true', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
||||
]);
|
||||
expect(map.get('nginx:latest')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps an internal-only image to false', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
||||
]);
|
||||
expect(map.get('nginx:latest')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips build-only services (no image)', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: null, publiclyExposed: true }]),
|
||||
]);
|
||||
expect(map.has(null as unknown as string)).toBe(false);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('true wins over false when the same image appears in multiple stacks', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
||||
exp('b', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
||||
]);
|
||||
expect(map.get('nginx:latest')).toBe(true);
|
||||
});
|
||||
|
||||
it('true stays true even when a later stack classifies the image internal', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'nginx:latest', publiclyExposed: true }]),
|
||||
exp('b', [{ image: 'nginx:latest', publiclyExposed: false }]),
|
||||
]);
|
||||
expect(map.get('nginx:latest')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the image appears only as internal across all stacks', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'postgres:15', publiclyExposed: false }]),
|
||||
exp('b', [{ image: 'postgres:15', publiclyExposed: false }]),
|
||||
]);
|
||||
expect(map.get('postgres:15')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves an absent image as undefined (no descriptor contains it)', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [{ image: 'redis:7', publiclyExposed: true }]),
|
||||
]);
|
||||
expect(map.get('nginx:latest')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles mixed images in the same stack', () => {
|
||||
const map = buildExposedImageMap([
|
||||
exp('a', [
|
||||
{ image: 'frontend:1', publiclyExposed: true },
|
||||
{ image: 'backend:1', publiclyExposed: false },
|
||||
]),
|
||||
]);
|
||||
expect(map.get('frontend:1')).toBe(true);
|
||||
expect(map.get('backend:1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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], {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
{scan.total_vulnerabilities} vulns · {scan.fixable_count} fixable · {scan.triggered_by}
|
||||
{scan.publicly_exposed === true && (
|
||||
<EvidenceTag tone="warn">Published service</EvidenceTag>
|
||||
)}
|
||||
</span>
|
||||
) : (loading ? 'Loading…' : 'No scan');
|
||||
|
||||
const footerContext = scan
|
||||
? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user