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