Files
sencho/backend/src/__tests__/exposure.test.ts
T
Anso fcd44f5693 fix(security): tie fixable CVE posture to image-update evidence (#1815)
* fix(security): tie fixable CVE posture to image-update evidence

Stop treating Trivy fixed_version alone as an Update affected images CTA. Reuse persisted ImageUpdateService status so Security only offers Review update when an applicable image update is confirmed, and otherwise surfaces waiting or uncertain remediation with truthful affordances.

* fix(security): move image-update recheck helper out of OverviewTab

Satisfy react-refresh/only-export-components so Frontend lint passes.

* fix(security): preserve posture reason image targets in Images drill-down

Carry affected image refs on overview reasons so public exposure and related CTAs open a clearable targeted Images list instead of an unfiltered hunt.

* fix(security): attach Networking exposure intent to posture targets

Preserve stack/service context and intentional classification on network-exposed Security reasons without suppressing risk or claiming Internet reachability.

* fix(security): persist Images exposure intent and triage scope

Standing image summaries carry Networking intent context with cap-safe aggregates, Anatomy Networking links, and scan-sheet triage that defaults to the current image.

* fix(security): clear CI lint errors for exposure helpers

* fix(security): stop intentional exposure from forcing Action needed

Separate exposure fact, intent correctness, and vulnerability drivers so
package fixed_version cannot recreate a permanent public_exposure blocker.

* fix(security): define Monitoring residual-risk narrative

* fix(security): define Secure via residual Crit/High triage

Replace triage-blind raw Crit/High Secure gating with residual material
risk so accepted and ignored stay Monitoring, while not_affected, false
positive, and fixed can clear residual without claiming no detections.

* fix(security): exclude rollback-hold images from Security scans

Hold-only sencho-rb tags are recovery state; keep them out of Trivy node scans, Security inventory, and Overview posture while dual-tagged images remain under their registry tag.

* fix(security): keep authoritative no-update rows after preview

Opening a stack page must not delete ok+false stack_update_status evidence; Security treats a missing row as uncertain and would flip waiting-upstream to unknown.
2026-08-12 15:02:14 -04:00

350 lines
11 KiB
TypeScript

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,
composeHealthcheck: 'absent' as const,
envKeys: [],
enabledProxyApiFlags: [],
dockerEndpointHosts: [],
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);
});
});
/**
* Mirrors ComposeService.refreshExposureCache success path:
* deriveStackExposure → upsertStackExposure (replace row) → buildExposedImageMap.
* On refresh failure ComposeService returns without upserting, so the prior
* beyond-loopback descriptor is retained (best-effort; not asserted here).
*/
describe('exposure cache refresh success path', () => {
it('clears prior beyond-loopback exposure after Compose correction', () => {
const prior = deriveStackExposure(
model({
services: [
svc({
image: 'web:1',
ports: [{ startPort: 80, endPort: 80, hostIp: '0.0.0.0', protocol: 'tcp' }],
}),
],
}),
'web',
NOW,
);
expect(prior.services[0].publiclyExposed).toBe(true);
expect(buildExposedImageMap([prior]).get('web:1')).toBe(true);
// Successful refresh replaces the cached descriptor for the stack.
const corrected = deriveStackExposure(
model({
services: [
svc({
image: 'web:1',
ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }],
}),
],
}),
'web',
NOW + 1,
);
expect(corrected.services[0].publiclyExposed).toBe(false);
expect(buildExposedImageMap([corrected]).get('web:1')).toBe(false);
});
});