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.
This commit is contained in:
Anso
2026-08-12 15:02:14 -04:00
committed by GitHub
parent c47b8eb8e9
commit fcd44f5693
49 changed files with 5596 additions and 308 deletions
@@ -220,6 +220,109 @@ describe('getImageScanSummaries', () => {
// No vuln-bearing scan exists, so scan_id falls back to the latest scan overall.
expect(summary.scan_id).toBe(secretScan);
});
it('excludes Sencho rollback-hold image refs from Security summaries', () => {
seedScan({
imageRef: 'sencho-rb/aaaaaaaaaaaa/web:hold',
scannersUsed: 'vuln',
scannedAt: 1000,
critical: 9,
high: 9,
});
seedScan({ imageRef: 'app:1', scannersUsed: 'vuln', scannedAt: 1000, critical: 1 });
const summaries = db().getImageScanSummaries(1);
expect(summaries['sencho-rb/aaaaaaaaaaaa/web:hold']).toBeUndefined();
expect(summaries['app:1']?.critical).toBe(1);
});
});
describe('rollback-hold exclusion from posture helpers', () => {
function rawDbHold() {
return (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
}
beforeEach(() => {
rawDbHold().prepare('DELETE FROM vulnerability_details').run();
rawDbHold().prepare('DELETE FROM cve_intel').run();
rawDbHold().prepare('DELETE FROM vulnerability_scans').run();
});
it('omits hold Crit/High, KEV, and risk-trend contributions while keeping registry-tag findings', () => {
const now = Date.now();
const holdScan = db().createVulnerabilityScan({
node_id: 1,
image_ref: 'sencho-rb/aaaaaaaaaaaa/web:hold',
image_digest: 'sha256:hold',
scanned_at: now,
total_vulnerabilities: 2,
critical_count: 1,
high_count: 1,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'CRITICAL',
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
});
const appScan = db().createVulnerabilityScan({
node_id: 1,
image_ref: 'app:1',
image_digest: 'sha256:app',
scanned_at: now,
total_vulnerabilities: 1,
critical_count: 0,
high_count: 1,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'HIGH',
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
});
const detail = (id: string, severity: 'CRITICAL' | 'HIGH') => ({
vulnerability_id: id,
pkg_name: `p-${id}`,
installed_version: '1',
fixed_version: null,
severity,
title: null,
description: null,
primary_url: null,
cvss_score: 9.0,
});
db().insertVulnerabilityDetails(holdScan, [detail('CVE-HOLD-CRIT', 'CRITICAL'), detail('CVE-HOLD-KEV', 'HIGH')]);
db().insertVulnerabilityDetails(appScan, [detail('CVE-APP-HIGH', 'HIGH')]);
db().replaceKev([{ cve_id: 'CVE-HOLD-KEV', date_added: '2024-01-01' }], now);
const critIds = db().getLatestCritHighVulnFindingsForNode(1).items.map((i) => i.vulnerability_id);
expect(critIds).toContain('CVE-APP-HIGH');
expect(critIds).not.toContain('CVE-HOLD-CRIT');
expect(critIds).not.toContain('CVE-HOLD-KEV');
expect(db().getLatestKevFindingsForNode(1).items).toHaveLength(0);
const cvssIds = db().getLatestCritHighFindingsWithCvssForNode(1).items.map((i) => i.vulnerability_id);
expect(cvssIds).toContain('CVE-APP-HIGH');
expect(cvssIds).not.toContain('CVE-HOLD-CRIT');
const trend = db().getDailyRiskTrend(1, 30);
expect(trend).toHaveLength(1);
expect(trend[0]).toMatchObject({ critical: 0, high: 1 });
});
});
describe('getLatestKevFindingsForNode', () => {
@@ -0,0 +1,69 @@
/**
* Exposure cache refresh success path via DatabaseService upsert + buildExposedImageMap.
* ComposeService.refreshExposureCache is best-effort: on failure it returns without
* upserting, so the prior beyond-loopback descriptor stays in stack_exposure.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { buildExposedImageMap, type StackExposure } from '../services/preflight/exposure';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
function db() {
return DatabaseService.getInstance();
}
function reset(): void {
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM stack_exposure').run();
}
function parseExposures(nodeId: number): StackExposure[] {
return db().getStackExposures(nodeId).map((row) => JSON.parse(row.descriptor) as StackExposure);
}
describe('exposure cache refresh (DatabaseService)', () => {
beforeEach(() => reset());
it('clears prior beyond-loopback exposure after a successful descriptor replace', () => {
const now = Date.now();
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [{
service: 'api',
image: 'web:1',
publiclyExposed: true,
reason: 'published-port',
bindings: ['0.0.0.0:80/tcp'],
}],
} satisfies StackExposure), now);
expect(buildExposedImageMap(parseExposures(1)).get('web:1')).toBe(true);
// Successful ComposeService.refreshExposureCache upserts the corrected descriptor.
// Refresh failure retains the prior row (ComposeService best-effort); not asserted here.
const correctedAt = now + 1;
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: correctedAt,
services: [{
service: 'api',
image: 'web:1',
publiclyExposed: false,
reason: null,
bindings: ['127.0.0.1:80/tcp'],
}],
} satisfies StackExposure), correctedAt);
expect(buildExposedImageMap(parseExposures(1)).get('web:1')).toBe(false);
});
});
+41
View File
@@ -306,3 +306,44 @@ describe('buildExposedImageMap', () => {
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);
});
});
@@ -0,0 +1,149 @@
/**
* GET /api/security/image-summaries — route-level publicly_exposed + exposure context enrichment.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
function db() {
return DatabaseService.getInstance();
}
function reset(): void {
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM vulnerability_scans').run();
raw.prepare('DELETE FROM stack_exposure').run();
raw.prepare('DELETE FROM stack_exposure_intent').run();
}
function seedScan(ref: string, now = Date.now()): void {
db().createVulnerabilityScan({
node_id: 1, image_ref: ref, image_digest: `sha256:${ref}`, scanned_at: now,
total_vulnerabilities: 0, critical_count: 0, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: null, os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
}
describe('GET /api/security/image-summaries', () => {
beforeEach(() => reset());
it('attaches publicly_exposed true/false/null without changing existing fields', async () => {
const now = Date.now();
for (const ref of ['pub:1', 'int:1', 'unknown:1']) seedScan(ref, now);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'a', image: 'pub:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
{ service: 'b', image: 'int:1', publiclyExposed: false, reason: null, bindings: [] },
],
}), now);
const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['pub:1']).toMatchObject({ image_ref: 'pub:1', publicly_exposed: true, scan_id: expect.any(Number) });
expect(res.body['int:1']).toMatchObject({ image_ref: 'int:1', publicly_exposed: false });
expect(res.body['int:1'].exposure_contexts).toBeUndefined();
expect(res.body['unknown:1']).toMatchObject({ image_ref: 'unknown:1', publicly_exposed: null });
});
it('attaches exposure contexts and summary for publicly exposed images', async () => {
const now = Date.now();
seedScan('pub:1', now);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'pub:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['pub:1']).toMatchObject({
publicly_exposed: true,
exposure_context_count: 1,
exposure_contexts_truncated: false,
exposure_context_summary: {
hasConflict: false,
hasUnclassified: false,
hasUnavailable: false,
allKnownIntentional: true,
},
});
expect(res.body['pub:1'].exposure_contexts[0]).toMatchObject({
stackName: 'web',
serviceName: 'api',
intentStatus: 'set',
exposureIntent: 'public',
});
});
it('sets hasConflict from hidden truncated contexts', async () => {
const now = Date.now();
seedScan('many:1', now);
const services = [];
for (let i = 0; i < 20; i += 1) {
services.push({
service: `svc${i}`,
image: 'many:1',
publiclyExposed: true,
reason: 'published-port',
bindings: ['0.0.0.0:80/tcp'],
});
}
services.push({
service: 'bad',
image: 'many:1',
publiclyExposed: true,
reason: 'published-port',
bindings: ['0.0.0.0:81/tcp'],
});
db().upsertStackExposure(1, 'web', JSON.stringify({ stack: 'web', computedAt: now, services }), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
db().setStackExposureIntent(1, 'web', 'bad', 'internal', 'admin');
const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['many:1'].exposure_contexts_truncated).toBe(true);
expect(res.body['many:1'].exposure_context_count).toBe(21);
expect(res.body['many:1'].exposure_context_summary.hasConflict).toBe(true);
expect(res.body['many:1'].exposure_contexts[0].intentConflict).toBe(true);
});
it('does not 500 when a stack exposure descriptor is malformed', async () => {
const now = Date.now();
seedScan('ok:1', now);
db().upsertStackExposure(1, 'bad', 'not-json{{{', now);
const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body['ok:1']).toMatchObject({ image_ref: 'ok:1', publicly_exposed: null });
});
it('requires authentication', async () => {
const res = await request(app).get('/api/security/image-summaries');
expect(res.status).toBe(401);
});
});
@@ -89,6 +89,8 @@ function resetSecurity(): void {
raw.prepare('DELETE FROM cve_suppressions').run();
raw.prepare('DELETE FROM misconfig_acknowledgements').run();
raw.prepare('DELETE FROM cve_intel').run();
raw.prepare('DELETE FROM stack_exposure').run();
raw.prepare('DELETE FROM stack_exposure_intent').run();
}
describe('GET /api/security/overview', () => {
@@ -318,6 +320,118 @@ describe('GET /api/security/overview', () => {
expect(res.body.knownExploited).toBe(1);
});
it('stays Monitoring when Crit/High are accepted residual risk', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'accepted:1', image_digest: 'sha256:accepted', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-2024-ACCEPT', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().createCveSuppression({
cve_id: 'CVE-2024-ACCEPT', pkg_name: null, image_pattern: null, reason: 'accepted residual',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted',
});
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.rawCritical).toBe(1);
expect(res.body.accepted).toBe(1);
expect(res.body.posture).toBe('Monitoring');
});
it('reads Secure when Crit/High are not_affected (raw detections may remain)', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'na:1', image_digest: 'sha256:na', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-2024-NA', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().createCveSuppression({
cve_id: 'CVE-2024-NA', pkg_name: null, image_pattern: null, reason: 'not in execute path',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'not_affected',
});
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.rawCritical).toBe(1);
expect(res.body.notAffected).toBe(1);
expect(res.body.posture).toBe('Secure');
});
it('stays Monitoring when Crit/High are ignored residual risk', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'ignored:1', image_digest: 'sha256:ignored', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-2024-IGN', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().createCveSuppression({
cve_id: 'CVE-2024-IGN', pkg_name: null, image_pattern: null, reason: 'ignored until patch',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'ignored',
});
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.rawCritical).toBe(1);
expect(res.body.posture).toBe('Monitoring');
});
it('reads Secure when Crit/High are false_positive (raw detections may remain)', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'fp:1', image_digest: 'sha256:fp', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-2024-FP', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().createCveSuppression({
cve_id: 'CVE-2024-FP', pkg_name: null, image_pattern: null, reason: 'scanner false positive',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'false_positive',
});
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.rawCritical).toBe(1);
expect(res.body.posture).toBe('Secure');
});
it('reads Secure when Crit/High are fixed (raw detections may remain)', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'fixed:1', image_digest: 'sha256:fixed', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [
{ vulnerability_id: 'CVE-2024-FIX', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
]);
db().createCveSuppression({
cve_id: 'CVE-2024-FIX', pkg_name: null, image_pattern: null, reason: 'patched in rebuild',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'fixed',
});
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.rawCritical).toBe(1);
expect(res.body.posture).toBe('Secure');
});
it('reads Secure when a scan completed with nothing actionable or severe', async () => {
db().createVulnerabilityScan({
node_id: 1, image_ref: 'clean:1', image_digest: 'sha256:clean', scanned_at: Date.now(),
@@ -340,6 +454,274 @@ describe('GET /api/security/overview', () => {
const res = await request(app).get('/api/security/overview');
expect(res.status).toBe(401);
});
it('treats exposed + fixed_version without intent as review/monitoring, not public_exposure blocker', async () => {
const now = Date.now();
for (const ref of ['exp-a:1', 'exp-b:1', 'safe:1']) {
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: ref, image_digest: `sha256:${ref}`, scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: `CVE-2024-${ref}`, pkg_name: 'x', installed_version: '1', fixed_version: '2',
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
}
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'a', image: 'exp-a:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
{ service: 'b', image: 'exp-b:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:81/tcp'] },
{ service: 'c', image: 'safe:1', publiclyExposed: false, reason: null, bindings: [] },
],
}), now);
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.posture).toBe('Monitoring');
const reasons = res.body.postureReasons as Array<{ kind: string; severity: string; targets?: Array<{ imageRef: string }> }>;
expect(reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined();
expect(reasons.find((r) => r.kind === 'elevated_exploit_risk')).toBeUndefined();
const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review');
const uncertain = reasons.find((r) => r.kind === 'update_check_uncertain' || r.kind === 'waiting_upstream');
expect(review || uncertain).toBeTruthy();
if (review) {
expect(review.targets?.map((t) => t.imageRef).sort()).toEqual(['exp-a:1', 'exp-b:1']);
}
expect(res.body.primaryAction).toBeNull();
});
it('intentional public + fixed_version without KEV/EPSS/image-update stays Monitoring', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'intent-fix:1', image_digest: 'sha256:intent-fix', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-INTENT', pkg_name: 'x', installed_version: '1', fixed_version: '2',
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'intent-fix:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.posture).toBe('Monitoring');
expect(res.body.publiclyExposed).toBe(1);
const reasons = res.body.postureReasons as Array<{ kind: string; severity: string }>;
expect(reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined();
expect(reasons.find((r) => r.kind === 'elevated_exploit_risk')).toBeUndefined();
expect(res.body.actionable).toBe(0);
});
it('intentional exposure + KEV yields known_exploited blocker with drivers', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'intent-kev:1', image_digest: 'sha256:intent-kev', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-IKEV', pkg_name: 'lib', installed_version: '1', fixed_version: null,
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().replaceKev([{ cve_id: 'CVE-2024-IKEV', date_added: '2024-01-01' }], now);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'intent-kev:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.posture).toBe('Action needed');
const kev = (res.body.postureReasons as Array<{
kind: string;
drivers?: Array<{ vulnerabilityId: string; imageRef: string }>;
}>).find((r) => r.kind === 'known_exploited');
expect(kev?.drivers).toEqual([{ vulnerabilityId: 'CVE-2024-IKEV', imageRef: 'intent-kev:1' }]);
expect((res.body.postureReasons as Array<{ kind: string; severity: string }>)
.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined();
});
it('intentional exposure + EPSS >= 0.1 yields elevated_exploit_risk blocker with drivers', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'intent-epss:1', image_digest: 'sha256:intent-epss', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-IEPSS', pkg_name: 'x', installed_version: '1', fixed_version: '2',
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().upsertEpss([{ cve_id: 'CVE-2024-IEPSS', epss_score: 0.15, epss_percentile: 0.9 }], now);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'intent-epss:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.posture).toBe('Action needed');
const elevated = (res.body.postureReasons as Array<{
kind: string;
severity: string;
drivers?: Array<{ vulnerabilityId: string; imageRef: string }>;
targets?: Array<{ imageRef: string }>;
}>).find((r) => r.kind === 'elevated_exploit_risk');
expect(elevated?.severity).toBe('blocker');
expect(elevated?.drivers).toEqual([{ vulnerabilityId: 'CVE-2024-IEPSS', imageRef: 'intent-epss:1' }]);
expect(elevated?.targets?.map((t) => t.imageRef)).toEqual(['intent-epss:1']);
expect(res.body.primaryAction.kind).toBe('elevated_exploit_risk');
});
it('internal intent + exposed yields public_exposure blocker with Review networking CTA', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'conflict:1', image_digest: 'sha256:conflict', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-CONFLICT', pkg_name: 'x', installed_version: '1', fixed_version: '2',
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'conflict:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'internal', 'admin');
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.posture).toBe('Action needed');
const blocker = (res.body.postureReasons as Array<{
kind: string;
severity: string;
label: string;
targets?: Array<{ imageRef: string; intentConflict?: boolean }>;
}>).find((r) => r.kind === 'public_exposure' && r.severity === 'blocker');
expect(blocker?.label).toBe('Exposure conflicts with declared intent');
expect(blocker?.targets?.[0]).toMatchObject({ imageRef: 'conflict:1', intentConflict: true });
expect(res.body.primaryAction).toMatchObject({
kind: 'public_exposure',
label: 'Review networking',
});
});
it('actionable excludes publiclyExposed-only intentional images', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'pub-only:1', image_digest: 'sha256:pub-only', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-PUBONLY', pkg_name: 'x', installed_version: '1', fixed_version: null,
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'api', image: 'pub-only:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
db().setStackExposureIntent(1, 'web', '', 'public', 'admin');
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.publiclyExposed).toBe(1);
expect(res.body.actionable).toBe(0);
expect(res.body.posture).toBe('Monitoring');
});
it('excludes fully suppressed exposed images from blocker and review targets', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'suppressed-exp:1', image_digest: 'sha256:se', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-SUPP', pkg_name: 'x', installed_version: '1', fixed_version: '2',
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().createCveSuppression({
cve_id: 'CVE-2024-SUPP', pkg_name: null, image_pattern: null, reason: 'accepted',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted',
});
db().upsertStackExposure(1, 'web', JSON.stringify({
stack: 'web',
computedAt: now,
services: [
{ service: 'a', image: 'suppressed-exp:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] },
],
}), now);
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.publiclyExposed).toBe(1);
const exposureReasons = (res.body.postureReasons as Array<{ kind: string; targets?: unknown[] }>)
.filter((r) => r.kind === 'public_exposure');
expect(exposureReasons).toHaveLength(0);
});
it('attaches known_exploited targets as distinct image refs', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'kev-img:1', image_digest: 'sha256:kevimg', scanned_at: now,
total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
db().insertVulnerabilityDetails(scanId, [{
vulnerability_id: 'CVE-2024-KEVT', pkg_name: 'lib', installed_version: '1', fixed_version: null,
severity: 'CRITICAL', title: null, description: null, primary_url: null,
}]);
db().replaceKev([{ cve_id: 'CVE-2024-KEVT', date_added: '2024-01-01' }], now);
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
const kev = (res.body.postureReasons as Array<{ kind: string; targets?: Array<{ imageRef: string }> }>)
.find((r) => r.kind === 'known_exploited');
expect(kev?.targets).toEqual([{ imageRef: 'kev-img:1' }]);
});
});
describe('GET /api/security/overview/trend', () => {
@@ -141,3 +141,20 @@ describe('GET /api/security/scans query wiring', () => {
]);
});
});
describe('POST /api/security/scan hold refs', () => {
it('rejects Sencho rollback-hold image refs with 400 before starting a scan', async () => {
const { default: TrivyService } = await import('../services/TrivyService');
vi.spyOn(TrivyService.getInstance(), 'isTrivyAvailable').mockReturnValue(true);
const begin = vi.spyOn(TrivyService.getInstance(), 'beginScan');
const res = await request(app)
.post('/api/security/scan')
.set('Cookie', adminCookie)
.send({ imageRef: 'sencho-rb/aaaaaaaaaaaa/web:hold' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/rollback-hold/i);
expect(begin).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import {
classifyExposedImages,
classifyImageExposureBucket,
collectKevDrivers,
POSTURE_DRIVER_CAP,
type ExposedFindingRow,
} from '../services/securityExposureClassification';
import type { PostureTarget } from '../services/securityPosture';
function intentionalTarget(imageRef: string, intent: 'public' | 'lan' | 'reverse-proxy' | 'temporary' = 'public'): PostureTarget {
return {
imageRef,
stackName: 'web',
serviceName: 'api',
intentStatus: 'set',
exposureIntent: intent,
intentConflict: false,
};
}
function conflictTarget(imageRef: string): PostureTarget {
return {
imageRef,
stackName: 'web',
serviceName: 'api',
intentStatus: 'set',
exposureIntent: 'internal',
intentConflict: true,
};
}
function unsetTarget(imageRef: string): PostureTarget {
return { imageRef, stackName: 'web', serviceName: 'api', intentStatus: 'unset' };
}
function mapsFor(imageRef: string, opts: {
findings: ExposedFindingRow[];
targets: PostureTarget[];
exposed?: boolean;
unsuppressed?: ExposedFindingRow[];
intel?: Map<string, { kev?: boolean; epssScore?: number | null }>;
}) {
const critHighByImage = new Map([[imageRef, opts.findings]]);
const exposedMap = new Map([[imageRef, opts.exposed ?? true]]);
const targetsByImage = new Map([[imageRef, opts.targets]]);
const unsuppressedByImage = new Map([[imageRef, opts.unsuppressed ?? opts.findings]]);
return {
critHighByImage,
exposedMap,
targetsByImage,
unsuppressedByImage,
intel: opts.intel ?? new Map(),
};
}
describe('classifyImageExposureBucket', () => {
it('returns conflict when any target conflicts', () => {
expect(classifyImageExposureBucket([
intentionalTarget('a:1'),
conflictTarget('a:1'),
])).toBe('conflict');
});
it('returns intentional when all complete contexts are intentional', () => {
expect(classifyImageExposureBucket([
intentionalTarget('a:1', 'public'),
intentionalTarget('a:1', 'lan'),
intentionalTarget('a:1', 'reverse-proxy'),
])).toBe('intentional');
});
it('returns unclassified for unset or empty targets', () => {
expect(classifyImageExposureBucket([unsetTarget('a:1')])).toBe('unclassified');
expect(classifyImageExposureBucket([])).toBe('unclassified');
});
});
describe('classifyExposedImages', () => {
it('intentional public/lan/reverse-proxy + fixed_version only → no conflict, no elevated, unclassified=0', () => {
for (const intent of ['public', 'lan', 'reverse-proxy'] as const) {
const imageRef = `img-${intent}:1`;
const result = classifyExposedImages(mapsFor(imageRef, {
findings: [{ vulnerability_id: 'CVE-1' }],
targets: [intentionalTarget(imageRef, intent)],
}));
expect(result).toMatchObject({
publiclyExposed: 1,
exposureIntentConflict: 0,
exposedUnclassified: 0,
elevatedExploitRisk: 0,
});
expect(result.elevatedExploitRiskDrivers).toEqual([]);
}
});
it('intentional + high EPSS → elevatedExploitRisk', () => {
const imageRef = 'exp:1';
const result = classifyExposedImages(mapsFor(imageRef, {
findings: [{ vulnerability_id: 'CVE-EPSS' }],
targets: [intentionalTarget(imageRef)],
intel: new Map([['CVE-EPSS', { epssScore: 0.42 }]]),
}));
expect(result.elevatedExploitRisk).toBe(1);
expect(result.exposureIntentConflict).toBe(0);
expect(result.exposedUnclassified).toBe(0);
expect(result.elevatedExploitRiskDrivers).toEqual([
{ vulnerabilityId: 'CVE-EPSS', imageRef },
]);
});
it('conflict (internal) + unsuppressed → exposureIntentConflict', () => {
const imageRef = 'bad:1';
const result = classifyExposedImages(mapsFor(imageRef, {
findings: [{ vulnerability_id: 'CVE-1' }],
targets: [conflictTarget(imageRef)],
}));
expect(result.exposureIntentConflict).toBe(1);
expect(result.exposedUnclassified).toBe(0);
expect(result.elevatedExploitRisk).toBe(0);
expect(result.exposureIntentConflictTargets[0]?.intentConflict).toBe(true);
});
it('unset intent → exposedUnclassified, not conflict', () => {
const imageRef = 'unset:1';
const result = classifyExposedImages(mapsFor(imageRef, {
findings: [{ vulnerability_id: 'CVE-1' }],
targets: [unsetTarget(imageRef)],
}));
expect(result.exposedUnclassified).toBe(1);
expect(result.exposureIntentConflict).toBe(0);
expect(result.elevatedExploitRisk).toBe(0);
});
it('fully empty unsuppressed → only publiclyExposed count', () => {
const imageRef = 'supp:1';
const result = classifyExposedImages(mapsFor(imageRef, {
findings: [{ vulnerability_id: 'CVE-1' }],
targets: [unsetTarget(imageRef)],
unsuppressed: [],
}));
expect(result).toMatchObject({
publiclyExposed: 1,
exposureIntentConflict: 0,
exposedUnclassified: 0,
elevatedExploitRisk: 0,
});
});
it('caps elevated drivers at POSTURE_DRIVER_CAP', () => {
const imageRef = 'many:1';
const findings: ExposedFindingRow[] = [];
const intel = new Map<string, { epssScore: number }>();
for (let i = 0; i < POSTURE_DRIVER_CAP + 5; i += 1) {
const id = `CVE-${i}`;
findings.push({ vulnerability_id: id });
intel.set(id, { epssScore: 0.5 });
}
const result = classifyExposedImages(mapsFor(imageRef, {
findings,
targets: [intentionalTarget(imageRef)],
intel,
}));
expect(result.elevatedExploitRisk).toBe(1);
expect(result.elevatedExploitRiskDrivers).toHaveLength(POSTURE_DRIVER_CAP);
});
});
describe('collectKevDrivers', () => {
it('collects unsuppressed KEV drivers and skips suppressed', () => {
const capped = collectKevDrivers([
{ imageRef: 'a:1', vulnerability_id: 'CVE-A', suppressed: false },
{ imageRef: 'a:1', vulnerability_id: 'CVE-B', suppressed: true },
{ imageRef: 'b:1', vulnerability_id: 'CVE-C' },
]);
expect(capped).toEqual({
drivers: [
{ vulnerabilityId: 'CVE-A', imageRef: 'a:1' },
{ vulnerabilityId: 'CVE-C', imageRef: 'b:1' },
],
driverCount: 2,
driversTruncated: false,
});
});
it('caps KEV drivers at POSTURE_DRIVER_CAP', () => {
const rows: Array<{ imageRef: string; vulnerability_id: string }> = [];
for (let i = 0; i < POSTURE_DRIVER_CAP + 3; i += 1) {
rows.push({ imageRef: 'img:1', vulnerability_id: `CVE-K${i}` });
}
const capped = collectKevDrivers(rows);
expect(capped.drivers).toHaveLength(POSTURE_DRIVER_CAP);
expect(capped.driverCount).toBe(POSTURE_DRIVER_CAP + 3);
expect(capped.driversTruncated).toBe(true);
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect, vi } from 'vitest';
import {
buildSecurityExposureTargets,
buildImageExposureContextRows,
packageImageExposureContexts,
allTargetsIntentionallyClassified,
allContextsAbsolutelyIntentional,
partialIntentionalWithUnavailable,
summarizeExposureContexts,
IMAGE_EXPOSURE_CONTEXT_CAP,
type ImageExposureContext,
} from '../services/securityExposureTargets';
import type { StackExposure } from '../services/preflight/exposure';
import type { ExposureContext } from '../services/network/exposureContext';
import type { PostureTarget } from '../services/securityPosture';
function exposure(stack: string, services: StackExposure['services']): StackExposure {
return { stack, services, computedAt: 1 };
}
function svc(
name: string,
image: string,
publiclyExposed = true,
reason: 'published-port' | 'host-network' | null = 'published-port',
): StackExposure['services'][number] {
return { service: name, image, publiclyExposed, reason, bindings: publiclyExposed ? ['0.0.0.0:80/tcp'] : [] };
}
describe('buildSecurityExposureTargets', () => {
it('emits public intent when stack intent is public', () => {
const getContext = vi.fn((): ExposureContext => ({
available: true,
stackIntent: 'public',
serviceIntents: {},
accessUrlPorts: new Set(),
hasAccessUrls: false,
}));
const targets = buildSecurityExposureTargets({
nodeId: 1,
exposures: [exposure('web', [svc('api', 'nginx:1')])],
qualifyingImageRefs: new Set(['nginx:1']),
getContext,
});
expect(getContext).toHaveBeenCalledTimes(1);
expect(targets).toEqual([{
imageRef: 'nginx:1',
stackName: 'web',
serviceName: 'api',
exposureReason: 'published-port',
intentStatus: 'set',
exposureIntent: 'public',
}]);
});
it('flags internal conflict when published beyond loopback', () => {
const targets = buildSecurityExposureTargets({
nodeId: 1,
exposures: [exposure('web', [svc('api', 'app:1')])],
qualifyingImageRefs: new Set(['app:1']),
getContext: () => ({
available: true,
stackIntent: 'internal',
serviceIntents: {},
accessUrlPorts: new Set(),
hasAccessUrls: false,
}),
});
expect(targets[0]).toMatchObject({
exposureIntent: 'internal',
intentStatus: 'set',
intentConflict: true,
});
});
it('treats null intent as unset', () => {
const targets = buildSecurityExposureTargets({
nodeId: 1,
exposures: [exposure('web', [svc('api', 'app:1')])],
qualifyingImageRefs: new Set(['app:1']),
getContext: () => ({
available: true,
stackIntent: null,
serviceIntents: {},
accessUrlPorts: new Set(),
hasAccessUrls: false,
}),
});
expect(targets[0].intentStatus).toBe('unset');
});
it('marks unavailable when context is unavailable', () => {
const targets = buildSecurityExposureTargets({
nodeId: 1,
exposures: [exposure('web', [svc('api', 'app:1')])],
qualifyingImageRefs: new Set(['app:1']),
getContext: () => ({ available: false }),
});
expect(targets[0].intentStatus).toBe('unavailable');
});
it('prefers service override over stack intent', () => {
const targets = buildSecurityExposureTargets({
nodeId: 1,
exposures: [exposure('web', [svc('api', 'app:1'), svc('worker', 'app:1')])],
qualifyingImageRefs: new Set(['app:1']),
getContext: () => ({
available: true,
stackIntent: 'public',
serviceIntents: { worker: 'internal' },
accessUrlPorts: new Set(),
hasAccessUrls: false,
}),
});
expect(targets.find((t) => t.serviceName === 'api')?.exposureIntent).toBe('public');
expect(targets.find((t) => t.serviceName === 'worker')?.intentConflict).toBe(true);
});
});
describe('intentional helpers and packaging', () => {
it('absolute intentional requires every context complete', () => {
const intentional: ImageExposureContext[] = [
{ stackName: 'a', serviceName: 's', exposureReason: 'published-port', intentStatus: 'set', exposureIntent: 'public' },
];
expect(allContextsAbsolutelyIntentional(intentional)).toBe(true);
const withUnavailable: ImageExposureContext[] = [
...intentional,
{ stackName: 'b', serviceName: 's', exposureReason: 'published-port', intentStatus: 'unavailable' },
];
expect(allContextsAbsolutelyIntentional(withUnavailable)).toBe(false);
expect(partialIntentionalWithUnavailable(withUnavailable)).toEqual({
partial: true,
unavailableCount: 1,
});
});
it('allTargetsIntentionallyClassified is false when any unavailable', () => {
const targets: PostureTarget[] = [
{ imageRef: 'a', stackName: 's', serviceName: 'a', intentStatus: 'set', exposureIntent: 'public' },
{ imageRef: 'a', stackName: 's', serviceName: 'b', intentStatus: 'unavailable' },
];
expect(allTargetsIntentionallyClassified(targets)).toBe(false);
});
it('allTargetsIntentionallyClassified is true for a complete public posture target', () => {
expect(allTargetsIntentionallyClassified([{
imageRef: 'a:1',
stackName: 'web',
serviceName: 'api',
intentStatus: 'set',
exposureIntent: 'public',
}])).toBe(true);
});
it('packageImageExposureContexts aggregates before cap and prefers conflict in display', () => {
const contexts: ImageExposureContext[] = [];
for (let i = 0; i < IMAGE_EXPOSURE_CONTEXT_CAP; i += 1) {
contexts.push({
stackName: `s${i}`,
serviceName: 'svc',
exposureReason: 'published-port',
intentStatus: 'set',
exposureIntent: 'public',
});
}
contexts.push({
stackName: 'hidden',
serviceName: 'svc',
exposureReason: 'published-port',
intentStatus: 'set',
exposureIntent: 'internal',
intentConflict: true,
});
const packaged = packageImageExposureContexts(contexts);
expect(packaged.exposure_contexts_truncated).toBe(true);
expect(packaged.exposure_context_count).toBe(IMAGE_EXPOSURE_CONTEXT_CAP + 1);
expect(packaged.exposure_context_summary.hasConflict).toBe(true);
expect(packaged.exposure_context_summary.allKnownIntentional).toBe(false);
expect(packaged.exposure_contexts[0].intentConflict).toBe(true);
expect(allContextsAbsolutelyIntentional(contexts, packaged.exposure_contexts_truncated)).toBe(false);
});
it('summarizeExposureContexts sets allKnownIntentional when only intentional set contexts exist among available', () => {
const summary = summarizeExposureContexts([
{ stackName: 'a', serviceName: 's', exposureReason: null, intentStatus: 'set', exposureIntent: 'lan' },
{ stackName: 'b', serviceName: 's', exposureReason: null, intentStatus: 'unavailable' },
]);
expect(summary).toMatchObject({
hasUnavailable: true,
allKnownIntentional: true,
hasConflict: false,
hasUnclassified: false,
});
});
});
describe('buildImageExposureContextRows', () => {
it('batches getContext once per stack', () => {
const getContext = vi.fn((): ExposureContext => ({
available: true,
stackIntent: 'public',
serviceIntents: {},
accessUrlPorts: new Set(),
hasAccessUrls: false,
}));
buildImageExposureContextRows({
nodeId: 1,
exposures: [exposure('web', [svc('a', 'i:1'), svc('b', 'i:2')])],
qualifyingImageRefs: new Set(['i:1', 'i:2']),
getContext,
});
expect(getContext).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,269 @@
import { describe, it, expect } from 'vitest';
import {
classifyImageRemediation,
buildUpdateServiceIndex,
} from '../services/securityImageRemediation';
import type { StackServiceStatus, StackUpdateDetail } from '../services/DatabaseService';
const HOUR = 60 * 60 * 1000;
const NOW = 1_700_000_000_000;
const FRESH_WINDOW = 4 * HOUR;
function service(partial: Partial<StackServiceStatus> & Pick<StackServiceStatus, 'service'>): StackServiceStatus {
return {
image: 'nginx:1.25',
hasUpdate: false,
checkStatus: 'ok',
lastError: null,
...partial,
};
}
function detail(
services: StackServiceStatus[],
opts: { checkedAt?: number; hasUpdate?: boolean; checkStatus?: 'ok' | 'partial' | 'failed' } = {},
): StackUpdateDetail {
return {
hasUpdate: opts.hasUpdate ?? services.some((s) => s.hasUpdate),
checkStatus: opts.checkStatus ?? 'ok',
lastError: null,
checkedAt: opts.checkedAt ?? NOW - HOUR,
services,
};
}
describe('buildUpdateServiceIndex', () => {
it('indexes declared image and runtimeImages through normalizeImageRef', () => {
const index = buildUpdateServiceIndex({
web: detail([
service({
service: 'app',
image: 'docker.io/library/nginx',
runtimeImages: ['nginx:1.25'],
}),
]),
});
expect(index.has('nginx:latest')).toBe(true);
expect(index.has('nginx:1.25')).toBe(true);
});
});
describe('classifyImageRemediation', () => {
it('counts confirmed updates only when hasUpdate and checkStatus are ok', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 3 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts).toEqual({
fixableWithImageUpdate: 3,
fixableWaitingUpstream: 0,
fixableUpdateUnknown: 0,
updateChecksDisabled: false,
imageRefsUpdateAvailable: ['nginx:1.25'],
imageRefsWaitingUpstream: [],
imageRefsUpdateUnknown: [],
});
});
it('treats sticky partial hasUpdate as uncertain, never update_available', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 2 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWithImageUpdate).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(2);
expect(facts.fixableWaitingUpstream).toBe(0);
});
it('treats not_checkable as uncertain, never waiting_upstream', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'myapp:local', count: 1 }],
details: {
web: detail([service({
service: 'app',
image: 'myapp:local',
hasUpdate: false,
checkStatus: 'not_checkable',
})]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWaitingUpstream).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(1);
});
it('authoritative negative becomes waiting_upstream when fresh', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 4 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWaitingUpstream).toBe(4);
expect(facts.fixableWithImageUpdate).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(0);
});
it('stale stack-level checkedAt yields uncertain, not waiting (R4)', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 1 }],
details: {
web: detail(
[service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })],
{ checkedAt: NOW - (FRESH_WINDOW + 1) },
),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWaitingUpstream).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(1);
});
it('digest-pinned finding does not match tag-declared service (uncertain)', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx@sha256:abc', count: 1 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWaitingUpstream).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(1);
});
it('disabled checks put all package-fix findings in uncertain', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 5 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]),
},
checksEnabled: false,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts).toEqual({
fixableWithImageUpdate: 0,
fixableWaitingUpstream: 0,
fixableUpdateUnknown: 5,
updateChecksDisabled: true,
imageRefsUpdateAvailable: [],
imageRefsWaitingUpstream: [],
imageRefsUpdateUnknown: ['nginx:1.25'],
});
});
it('missing stack membership is uncertain', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'orphan:1', count: 2 }],
details: {},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableUpdateUnknown).toBe(2);
});
it('matches via runtimeImages when declared image differs', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25.3', count: 1 }],
details: {
web: detail([service({
service: 'app',
image: 'nginx:1.25',
runtimeImages: ['nginx:1.25.3'],
hasUpdate: true,
checkStatus: 'ok',
})]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWithImageUpdate).toBe(1);
});
it('cron-sized freshness window still allows authoritative negatives', () => {
// Daily cron → 2×24h = 48h window (clamped max). A check from 12h ago is fresh.
const cronWindow = 48 * HOUR;
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 1 }],
details: {
web: detail(
[service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })],
{ checkedAt: NOW - 12 * HOUR },
),
},
checksEnabled: true,
freshnessWindowMs: cronWindow,
now: NOW,
});
expect(facts.fixableWaitingUpstream).toBe(1);
});
it('confirmed update on one stack wins over sibling partial sticky hasUpdate', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 1 }],
details: {
web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]),
api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWithImageUpdate).toBe(1);
expect(facts.fixableUpdateUnknown).toBe(0);
});
it('partial-only sticky hasUpdate stays uncertain', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.25', count: 1 }],
details: {
api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWithImageUpdate).toBe(0);
expect(facts.fixableUpdateUnknown).toBe(1);
});
it('returns raw finding image_ref even when stack match used normalizeImageRef', () => {
const facts = classifyImageRemediation({
findings: [{ image_ref: 'nginx:1.14', count: 2 }],
details: {
web: detail([service({
service: 'app',
image: 'docker.io/library/nginx:1.14',
hasUpdate: true,
checkStatus: 'ok',
})]),
},
checksEnabled: true,
freshnessWindowMs: FRESH_WINDOW,
now: NOW,
});
expect(facts.fixableWithImageUpdate).toBe(2);
expect(facts.imageRefsUpdateAvailable).toEqual(['nginx:1.14']);
});
});
+290 -42
View File
@@ -6,14 +6,20 @@ function facts(o: Partial<SecurityPostureFacts> = {}): SecurityPostureFacts {
scannerAvailable: true,
hasCompletedScan: true,
fixableCriticalHigh: 0,
fixableWithImageUpdate: 0,
fixableWaitingUpstream: 0,
fixableUpdateUnknown: 0,
updateChecksDisabled: false,
secrets: 0,
dangerousCompose: 0,
knownExploited: 0,
publiclyExposed: 0,
exposedBlocker: 0,
exposedReview: 0,
exposureIntentConflict: 0,
exposedUnclassified: 0,
elevatedExploitRisk: 0,
rawCritical: 0,
rawHigh: 0,
residualCriticalHigh: 0,
staleScans: 0,
failedScans: 0,
needsReview: 0,
@@ -21,6 +27,14 @@ function facts(o: Partial<SecurityPostureFacts> = {}): SecurityPostureFacts {
};
}
function allCopy(f: SecurityPostureFacts): string {
const { reasons, primaryAction } = derivePostureReasons(f);
return [
...reasons.map((r) => `${r.label} ${r.description}`),
primaryAction?.label ?? '',
].join(' | ');
}
describe('deriveSecurityPosture', () => {
it('is Unknown when the scanner is unavailable', () => {
expect(deriveSecurityPosture(facts({ scannerAvailable: false, rawCritical: 9 }))).toBe('Unknown');
@@ -30,8 +44,21 @@ describe('deriveSecurityPosture', () => {
expect(deriveSecurityPosture(facts({ hasCompletedScan: false, rawCritical: 9 }))).toBe('Unknown');
});
it('is Action needed when a Critical/High is fixable', () => {
expect(deriveSecurityPosture(facts({ fixableCriticalHigh: 1, rawCritical: 5, rawHigh: 5 }))).toBe('Action needed');
it('is Monitoring when package-fix exists but no confirmed image update', () => {
expect(deriveSecurityPosture(facts({
fixableCriticalHigh: 4,
fixableWaitingUpstream: 4,
rawCritical: 5,
rawHigh: 5,
}))).toBe('Monitoring');
});
it('is Action needed when a confirmed image update is available', () => {
expect(deriveSecurityPosture(facts({
fixableCriticalHigh: 1,
fixableWithImageUpdate: 1,
rawCritical: 5,
}))).toBe('Action needed');
});
it('is Action needed for a detected secret', () => {
@@ -46,23 +73,52 @@ describe('deriveSecurityPosture', () => {
expect(deriveSecurityPosture(facts({ knownExploited: 1, fixableCriticalHigh: 0, rawCritical: 1 }))).toBe('Action needed');
});
it('is Action needed when exposedBlocker > 0 (KEV, fixable, or elevated EPSS on a public interface)', () => {
expect(deriveSecurityPosture(facts({ exposedBlocker: 1 }))).toBe('Action needed');
it('is Action needed when exposureIntentConflict > 0', () => {
expect(deriveSecurityPosture(facts({ exposureIntentConflict: 1 }))).toBe('Action needed');
});
it('is Monitoring when publiclyExposed > 0 but exposedBlocker is 0 (review-only exposure)', () => {
expect(deriveSecurityPosture(facts({ publiclyExposed: 3, exposedReview: 3, rawCritical: 2 }))).toBe('Monitoring');
it('is Action needed when elevatedExploitRisk > 0', () => {
expect(deriveSecurityPosture(facts({ elevatedExploitRisk: 1, publiclyExposed: 1 }))).toBe('Action needed');
});
it('is Monitoring when Critical/High exist but nothing is actionable', () => {
expect(deriveSecurityPosture(facts({ rawCritical: 3, rawHigh: 7 }))).toBe('Monitoring');
it('keeps Action needed for intent conflict even with authoritative no-update (R3)', () => {
expect(deriveSecurityPosture(facts({
fixableCriticalHigh: 2,
fixableWaitingUpstream: 2,
exposureIntentConflict: 1,
}))).toBe('Action needed');
});
it('is Monitoring when publiclyExposed > 0 but only unclassified review (no conflict/elevated)', () => {
expect(deriveSecurityPosture(facts({
publiclyExposed: 3,
exposedUnclassified: 3,
rawCritical: 2,
}))).toBe('Monitoring');
});
it('is Monitoring for intentional exposure with package-fix only (no elevated/conflict)', () => {
expect(deriveSecurityPosture(facts({
publiclyExposed: 1,
fixableCriticalHigh: 2,
fixableWaitingUpstream: 2,
rawCritical: 2,
}))).toBe('Monitoring');
});
it('is Monitoring when residual Critical/High remain (including accepted risk)', () => {
expect(deriveSecurityPosture(facts({ residualCriticalHigh: 5 }))).toBe('Monitoring');
});
it('is Secure when raw Crit/High remain but residual is cleared', () => {
expect(deriveSecurityPosture(facts({ rawCritical: 5, rawHigh: 2, residualCriticalHigh: 0 }))).toBe('Secure');
});
it('is Monitoring when only review/info reasons exist', () => {
expect(deriveSecurityPosture(facts({ exposedReview: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring');
expect(deriveSecurityPosture(facts({ exposedUnclassified: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring');
});
it('is Secure when a scan completed and nothing is actionable or severe', () => {
it('is Secure when a scan completed and nothing is actionable or residual', () => {
expect(deriveSecurityPosture(facts())).toBe('Secure');
});
});
@@ -74,8 +130,18 @@ describe('derivePostureReasons', () => {
expect(primaryAction).toBeNull();
});
it('returns a blocker reason for fixable findings', () => {
const { reasons } = derivePostureReasons(facts({ fixableCriticalHigh: 4 }));
it('does not emit fixable_cve blocker from package-fix alone', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({
fixableCriticalHigh: 4,
fixableWaitingUpstream: 4,
}));
expect(reasons.find((r) => r.kind === 'fixable_cve')).toBeUndefined();
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'waiting_upstream', count: 4, severity: 'review' }));
expect(primaryAction).toBeNull();
});
it('returns a blocker reason for confirmed image updates', () => {
const { reasons } = derivePostureReasons(facts({ fixableWithImageUpdate: 4, fixableCriticalHigh: 4 }));
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'fixable_cve', count: 4, severity: 'blocker' }));
});
@@ -84,6 +150,16 @@ describe('derivePostureReasons', () => {
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'known_exploited', count: 2, severity: 'blocker' }));
});
it('returns a blocker reason for elevated exploit risk', () => {
const { reasons } = derivePostureReasons(facts({ elevatedExploitRisk: 1 }));
expect(reasons).toContainEqual(expect.objectContaining({
kind: 'elevated_exploit_risk',
count: 1,
severity: 'blocker',
label: 'Elevated exploit risk on network-exposed workload',
}));
});
it('returns a blocker reason for secrets', () => {
const { reasons } = derivePostureReasons(facts({ secrets: 3 }));
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'secret', count: 3, severity: 'blocker' }));
@@ -94,14 +170,30 @@ describe('derivePostureReasons', () => {
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'dangerous_compose', count: 5, severity: 'blocker' }));
});
it('returns a blocker reason for exposedBlocker', () => {
const { reasons } = derivePostureReasons(facts({ exposedBlocker: 1 }));
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 1, severity: 'blocker' }));
it('returns a public_exposure blocker for exposureIntentConflict only', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({ exposureIntentConflict: 1 }));
expect(reasons).toContainEqual(expect.objectContaining({
kind: 'public_exposure',
count: 1,
severity: 'blocker',
label: 'Exposure conflicts with declared intent',
}));
expect(primaryAction).toEqual({
label: 'Review networking',
targetTab: 'images',
kind: 'public_exposure',
});
});
it('returns a review reason for exposedReview', () => {
const { reasons } = derivePostureReasons(facts({ exposedReview: 2 }));
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 2, severity: 'review' }));
it('returns a public_exposure review for exposedUnclassified only', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({ exposedUnclassified: 2 }));
expect(reasons).toContainEqual(expect.objectContaining({
kind: 'public_exposure',
count: 2,
severity: 'review',
label: 'Network-exposed images not yet classified',
}));
expect(primaryAction).toBeNull();
});
it('returns a review reason for needsReview', () => {
@@ -115,11 +207,27 @@ describe('derivePostureReasons', () => {
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'failed_scan', count: 1, severity: 'info' }));
});
it('returns uncertain review reason for unknown remediation', () => {
const { reasons } = derivePostureReasons(facts({
fixableCriticalHigh: 2,
fixableUpdateUnknown: 2,
}));
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'update_check_uncertain', count: 2, severity: 'review' }));
});
it('explains disabled checks in uncertain description', () => {
const { reasons } = derivePostureReasons(facts({
fixableUpdateUnknown: 1,
updateChecksDisabled: true,
}));
const uncertain = reasons.find((r) => r.kind === 'update_check_uncertain');
expect(uncertain?.description).toMatch(/disabled/i);
});
it('returns ALL reasons regardless of posture state', () => {
// Even with no scanner (Unknown posture), the facts produce reasons.
const { reasons } = derivePostureReasons(facts({
scannerAvailable: false,
fixableCriticalHigh: 4,
fixableWithImageUpdate: 4,
staleScans: 1,
}));
expect(reasons).toHaveLength(2);
@@ -127,13 +235,34 @@ describe('derivePostureReasons', () => {
expect(reasons[1].kind).toBe('stale_scan');
});
it('sets primaryAction to the first blocker (fixable_cve priority)', () => {
it('sets primaryAction to Review update when image update is confirmed', () => {
const { primaryAction } = derivePostureReasons(facts({
fixableCriticalHigh: 3,
fixableWithImageUpdate: 3,
knownExploited: 1,
secrets: 2,
}));
expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images', kind: 'fixable_cve' });
expect(primaryAction).toEqual({ label: 'Review update', targetTab: 'images', kind: 'fixable_cve' });
});
it('falls through to KEV when only waiting upstream for package fixes', () => {
const { primaryAction, reasons } = derivePostureReasons(facts({
fixableCriticalHigh: 3,
fixableWaitingUpstream: 3,
knownExploited: 1,
}));
expect(primaryAction).toEqual({ label: 'Review exploited findings', targetTab: 'images', kind: 'known_exploited' });
expect(reasons.some((r) => r.kind === 'waiting_upstream')).toBe(true);
});
it('R3: intent-conflict primary action when waiting upstream, never Update affected images', () => {
const { primaryAction, reasons } = derivePostureReasons(facts({
fixableCriticalHigh: 2,
fixableWaitingUpstream: 2,
exposureIntentConflict: 1,
}));
expect(primaryAction).toEqual({ label: 'Review networking', targetTab: 'images', kind: 'public_exposure' });
expect(reasons.some((r) => r.label === 'Update affected images' || r.description.includes('Update affected images'))).toBe(false);
expect(primaryAction?.label).not.toBe('Update affected images');
});
it('falls through to the next blocker when the first is absent', () => {
@@ -143,29 +272,148 @@ describe('derivePostureReasons', () => {
it('returns null primaryAction when no blockers exist', () => {
const { primaryAction } = derivePostureReasons(facts({
exposedReview: 1, needsReview: 2, staleScans: 1, failedScans: 0,
exposedUnclassified: 1, needsReview: 2, staleScans: 1, failedScans: 0,
}));
expect(primaryAction).toBeNull();
});
it('each blocker reason has a targetTab matching a valid Security tab', () => {
const validTabs = ['images', 'secrets', 'compose', 'history', 'suppressions', 'scanner'];
const { reasons } = derivePostureReasons(facts({
fixableCriticalHigh: 1, secrets: 1, dangerousCompose: 1,
knownExploited: 1, exposedBlocker: 1,
it('never claims a security fix or that an update fixes findings', () => {
const copy = allCopy(facts({
fixableWithImageUpdate: 2,
fixableWaitingUpstream: 1,
fixableUpdateUnknown: 1,
}));
for (const r of reasons) {
expect(validTabs).toContain(r.targetTab);
}
expect(copy.toLowerCase()).not.toContain('security fix available');
expect(copy.toLowerCase()).not.toMatch(/fixes the/);
expect(copy).not.toContain('Update affected images');
});
// Invariant: Action needed posture always has at least one blocker reason.
it('Action needed posture always has at least one blocker reason', () => {
const f = facts({ fixableCriticalHigh: 1 });
const posture = deriveSecurityPosture(f);
const { reasons } = derivePostureReasons(f);
if (posture === 'Action needed') {
expect(reasons.some((r) => r.severity === 'blocker')).toBe(true);
}
it('attaches targets to reasons and omits them when empty', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({
exposureIntentConflict: 2,
exposureIntentConflictTargets: [{ imageRef: 'a:1' }, { imageRef: 'b:1' }],
exposedUnclassified: 1,
exposedUnclassifiedTargets: [{ imageRef: 'c:1' }],
knownExploited: 3,
knownExploitedTargets: ['kev:1'],
}));
const blocker = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker');
const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review');
const kev = reasons.find((r) => r.kind === 'known_exploited');
expect(blocker?.targets).toEqual([{ imageRef: 'a:1' }, { imageRef: 'b:1' }]);
expect(review?.targets).toEqual([{ imageRef: 'c:1' }]);
expect(kev?.targets).toEqual([{ imageRef: 'kev:1' }]);
expect(primaryAction?.kind).toBe('known_exploited');
expect(primaryAction?.targets).toEqual([{ imageRef: 'kev:1' }]);
});
it('omits targets field when target arrays are empty', () => {
const { reasons } = derivePostureReasons(facts({
exposureIntentConflict: 1,
exposureIntentConflictTargets: [],
}));
const blocker = reasons.find((r) => r.kind === 'public_exposure');
expect(blocker?.targets).toBeUndefined();
});
it('primaryAction for public_exposure copies conflict targets, not unclassified review', () => {
const { primaryAction } = derivePostureReasons(facts({
exposureIntentConflict: 1,
exposureIntentConflictTargets: [{ imageRef: 'block:1' }],
exposedUnclassified: 2,
exposedUnclassifiedTargets: [{ imageRef: 'rev:1' }, { imageRef: 'rev:2' }],
}));
expect(primaryAction).toEqual({
label: 'Review networking',
targetTab: 'images',
kind: 'public_exposure',
targets: [{ imageRef: 'block:1' }],
});
});
it('attaches elevated exploit risk targets and drivers', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({
elevatedExploitRisk: 1,
elevatedExploitRiskTargets: [{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }],
elevatedExploitRiskDrivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }],
}));
const elevated = reasons.find((r) => r.kind === 'elevated_exploit_risk');
expect(elevated?.targets).toEqual([{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }]);
expect(elevated?.drivers).toEqual([{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }]);
expect(elevated?.driverCount).toBe(1);
expect(elevated?.driversTruncated).toBe(false);
expect(primaryAction).toEqual({
label: 'Review driving findings',
targetTab: 'images',
kind: 'elevated_exploit_risk',
targets: [{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }],
drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }],
driverCount: 1,
driversTruncated: false,
});
});
it('conflict description names intent mismatch and never claims Internet reachability', () => {
const { reasons, primaryAction } = derivePostureReasons(facts({
exposureIntentConflict: 1,
exposureIntentConflictTargets: [{
imageRef: 'a:1',
stackName: 'web',
serviceName: 'api',
intentStatus: 'set',
exposureIntent: 'internal',
intentConflict: true,
}],
}));
const blocker = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker');
expect(blocker?.label).toBe('Exposure conflicts with declared intent');
expect(blocker?.description).toContain('conflicts with declared Networking intent');
expect(blocker?.description).toContain('Review networking');
expect(blocker?.description.toLowerCase()).not.toContain('internet');
expect(primaryAction?.label).toBe('Review networking');
});
it('unclassified review description prompts classification when intent is unset', () => {
const { reasons } = derivePostureReasons(facts({
exposedUnclassified: 1,
exposedUnclassifiedTargets: [{ imageRef: 'a:1', intentStatus: 'unset' }],
}));
const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review');
expect(review?.description).toContain('not yet classified');
expect(review?.description).toContain('Set exposure intent in Networking');
});
it('KEV plus intentional exposure context still Action needed via known_exploited', () => {
expect(deriveSecurityPosture(facts({
knownExploited: 1,
publiclyExposed: 1,
}))).toBe('Action needed');
});
it('unavailable-only unclassified does not add the unset classification sentence', () => {
const { reasons } = derivePostureReasons(facts({
exposedUnclassified: 1,
exposedUnclassifiedTargets: [{
imageRef: 'a:1',
stackName: 's',
serviceName: 'a',
intentStatus: 'unavailable',
}],
}));
const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review');
expect(review?.description).not.toContain('Set exposure intent in Networking');
expect(review?.description).toContain('not yet classified');
});
it('notes unverified services when intentional contexts mix with unavailable', () => {
const { reasons } = derivePostureReasons(facts({
exposedUnclassified: 1,
exposedUnclassifiedTargets: [
{ imageRef: 'a:1', stackName: 's', serviceName: 'a', intentStatus: 'set', exposureIntent: 'public' },
{ imageRef: 'a:1', stackName: 's', serviceName: 'b', intentStatus: 'unavailable' },
],
}));
const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review');
expect(review?.description).toContain('could not be verified');
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import {
isFullySyntheticHoldImage,
isSenchoRollbackHoldRef,
SENCHO_ROLLBACK_HOLD_PREFIX,
SENCHO_ROLLBACK_HOLD_SQL_LIKE,
} from '../utils/senchoRollbackHold';
describe('senchoRollbackHold constants', () => {
it('keeps the SQL LIKE pattern aligned with the JS prefix', () => {
expect(SENCHO_ROLLBACK_HOLD_SQL_LIKE).toBe(`${SENCHO_ROLLBACK_HOLD_PREFIX}%`);
});
});
describe('isSenchoRollbackHoldRef', () => {
it('matches Sencho synthetic rollback-hold tags', () => {
expect(isSenchoRollbackHoldRef('sencho-rb/aaaaaaaaaaaa/web:hold')).toBe(true);
expect(isSenchoRollbackHoldRef('sencho-rb/x/svc:hold')).toBe(true);
});
it('rejects ordinary registry refs', () => {
expect(isSenchoRollbackHoldRef('nginx:1.25')).toBe(false);
expect(isSenchoRollbackHoldRef('ghcr.io/org/app:latest')).toBe(false);
expect(isSenchoRollbackHoldRef('stack:web')).toBe(false);
});
});
describe('isFullySyntheticHoldImage', () => {
it('is true when every tag is a hold tag', () => {
expect(isFullySyntheticHoldImage(['sencho-rb/aaaaaaaaaaaa/web:hold'])).toBe(true);
});
it('is false when a registry tag is present alongside a hold tag', () => {
expect(isFullySyntheticHoldImage([
'myregistry/app:1.4',
'sencho-rb/aaaaaaaaaaaa/app:hold',
])).toBe(false);
});
it('is false for empty tag lists', () => {
expect(isFullySyntheticHoldImage([])).toBe(false);
});
});
@@ -2,7 +2,7 @@
* Unit tests for the read-time CVE suppression filter.
*/
import { describe, it, expect } from 'vitest';
import { applySuppressions, findSuppression } from '../utils/suppression-filter';
import { applySuppressions, countsTowardResidualCriticalHigh, findSuppression } from '../utils/suppression-filter';
import type { CveSuppression } from '../services/DatabaseService';
const NOW = 1_700_000_000_000;
@@ -227,3 +227,22 @@ describe('applySuppressions', () => {
expect(elapsed).toBeLessThan(1500);
});
});
describe('countsTowardResidualCriticalHigh', () => {
it('counts unsuppressed findings as residual', () => {
expect(countsTowardResidualCriticalHigh({ suppressed: false })).toBe(true);
expect(countsTowardResidualCriticalHigh({ suppressed: false, triage_status: 'needs_review' })).toBe(true);
expect(countsTowardResidualCriticalHigh({ suppressed: false, triage_status: 'affected' })).toBe(true);
});
it('keeps accepted and ignored residual (do not turn Secure green)', () => {
expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'accepted' })).toBe(true);
expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'ignored' })).toBe(true);
});
it('clears not_affected, false_positive, and fixed from residual', () => {
expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'not_affected' })).toBe(false);
expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'false_positive' })).toBe(false);
expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'fixed' })).toBe(false);
});
});
@@ -76,6 +76,29 @@ describe('TrivyService.scanNode', () => {
expect(result.severity).toMatchObject({ critical: 1, high: 2 });
});
it('skips Sencho rollback-hold tags while still scanning dual-tagged images via the registry tag', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getImages: async () => [
{ RepoTags: ['sencho-rb/aaaaaaaaaaaa/web:hold'] },
{ RepoTags: ['myregistry/app:1.4', 'sencho-rb/bbbbbbbbbbbb/app:hold'] },
],
} as never);
const run = vi.spyOn(svc(), 'runScanAndPersist').mockResolvedValue(fakeRow({ image_ref: 'myregistry/app:1.4' }));
await svc().scanNode(1, { vulns: true, secrets: false, misconfig: false });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith('myregistry/app:1.4', 1, 'manual', null, { scanners: ['vuln'] });
});
it('rejects scanImage for Sencho rollback-hold refs before touching Docker or Trivy', async () => {
const digest = vi.spyOn(svc() as unknown as { getImageDigest: (r: string, n: number) => Promise<string | null> }, 'getImageDigest');
await expect(svc().scanImage('sencho-rb/aaaaaaaaaaaa/web:hold', 1)).rejects.toThrow(
/rollback-hold images are recovery state/i,
);
expect(digest).not.toHaveBeenCalled();
});
it('scans secrets only and keys the digest cache on the scanner set', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getImages: async () => [{ RepoTags: ['a:1'] }] } as never);
// Force a digest so the cache lookup runs; return null so the scan proceeds.
@@ -118,6 +118,43 @@ describe('ImageUpdateService.commitPreviewClear', () => {
expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined();
});
it('keeps an ok+false row', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'ok', null, [
{ service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'ok', lastError: null },
]);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('absent');
expect(db.getStackUpdateDetail(nodeId).web).toMatchObject({
hasUpdate: false,
checkStatus: 'ok',
services: [
expect.objectContaining({
service: 'web',
image: 'nginx:1.2.3',
hasUpdate: false,
checkStatus: 'ok',
}),
],
});
});
it('clears a failed row even when hasUpdate is false', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'failed', 'timeout', [
{ service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'failed', lastError: 'timeout' },
]);
const svc = ImageUpdateService.getInstance();
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared');
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
});
it('returns absent when no row exists', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
@@ -303,6 +340,39 @@ describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
}));
});
it('POST keeps an ok+false scanner row', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'ok', null, [
{ service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'ok', lastError: null },
]);
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.reconciled).toBe(false);
expect(db.getStackUpdateDetail(nodeId).web).toMatchObject({
hasUpdate: false,
checkStatus: 'ok',
services: [
expect.objectContaining({
service: 'web',
image: 'nginx:1.2.3',
hasUpdate: false,
checkStatus: 'ok',
}),
],
});
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
it('POST does not mutate on partial negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;