mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 11:47:01 +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:
@@ -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], {
|
||||
|
||||
Reference in New Issue
Block a user