mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user