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