mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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!;
|
||||
|
||||
+211
-40
@@ -11,12 +11,31 @@ import { isValidStackName } from '../utils/validation';
|
||||
import { FleetSyncService } from '../services/FleetSyncService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
import { applySuppressions, isTriageStatus, isTriageJustification } from '../utils/suppression-filter';
|
||||
import { isSenchoRollbackHoldRef } from '../utils/senchoRollbackHold';
|
||||
import {
|
||||
applySuppressions,
|
||||
countsTowardResidualCriticalHigh,
|
||||
isTriageStatus,
|
||||
isTriageJustification,
|
||||
} from '../utils/suppression-filter';
|
||||
import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter';
|
||||
import { generateSarif } from '../services/SarifExporter';
|
||||
import { generateOpenVex } from '../services/OpenVexExporter';
|
||||
import { deriveSecurityPosture, derivePostureReasons, HIGH_EPSS_THRESHOLD, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction } from '../services/securityPosture';
|
||||
import { buildExposedImageMap } from '../services/preflight/exposure';
|
||||
import { deriveSecurityPosture, derivePostureReasons, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction, type PostureTarget } from '../services/securityPosture';
|
||||
import { classifyImageRemediation, type RemediationFindingInput } from '../services/securityImageRemediation';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { buildExposedImageMap, type StackExposure } from '../services/preflight/exposure';
|
||||
import {
|
||||
buildImageExposureContextRows,
|
||||
buildSecurityExposureTargets,
|
||||
packageExposureContextsByImage,
|
||||
type PackagedImageExposureContexts,
|
||||
} from '../services/securityExposureTargets';
|
||||
import {
|
||||
classifyExposedImages,
|
||||
collectKevDrivers,
|
||||
collectPackageFixDrivers,
|
||||
} from '../services/securityExposureClassification';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -187,7 +206,10 @@ interface SecurityOverviewResponse {
|
||||
needsReview: number;
|
||||
accepted: number;
|
||||
notAffected: number;
|
||||
/** Total actionable items, for the "N actions" affordance. */
|
||||
/**
|
||||
* Legacy mixed-unit sum of canonical blocker counts (image updates, secrets,
|
||||
* Compose, KEV, elevated EPSS, intent conflict). Prefer posture / reasons.
|
||||
*/
|
||||
actionable: number;
|
||||
posture: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
@@ -196,6 +218,8 @@ interface SecurityOverviewResponse {
|
||||
postureReasons: PostureReason[];
|
||||
/** Highest-priority action for the masthead CTA, or null when no blockers. */
|
||||
primaryAction: PostureAction | null;
|
||||
/** True when image-update checks are disabled and uncertain remediation exists. */
|
||||
updateChecksDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const securityRouter = Router();
|
||||
@@ -442,6 +466,10 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
|
||||
res.status(400).json({ error: 'Invalid imageRef format' });
|
||||
return;
|
||||
}
|
||||
if (isSenchoRollbackHoldRef(rawImageRef)) {
|
||||
res.status(400).json({ error: 'Sencho rollback-hold images are recovery state and cannot be scanned' });
|
||||
return;
|
||||
}
|
||||
const imageRef = rawImageRef;
|
||||
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
|
||||
const force = req.body?.force === true;
|
||||
@@ -711,8 +739,52 @@ securityRouter.get(
|
||||
|
||||
securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId);
|
||||
res.json(summaries);
|
||||
const db = DatabaseService.getInstance();
|
||||
const summaries = db.getImageScanSummaries(req.nodeId);
|
||||
// Route-level enrichment only: leave DatabaseService ScanSummary untouched.
|
||||
// Fail soft on exposure read/parse so Resources and post-scan refresh stay up.
|
||||
let exposedMap: Map<string, boolean> | null = null;
|
||||
let packagedByImage: Map<string, PackagedImageExposureContexts> | null = null;
|
||||
try {
|
||||
const exposures = db.getStackExposures(req.nodeId);
|
||||
const parsed = exposures.map((r) => {
|
||||
try { return JSON.parse(r.descriptor) as StackExposure; } catch { return null; }
|
||||
}).filter((e): e is StackExposure => e !== null);
|
||||
exposedMap = buildExposedImageMap(parsed);
|
||||
const exposedRefs = new Set(
|
||||
[...exposedMap].filter(([, exposed]) => exposed).map(([ref]) => ref),
|
||||
);
|
||||
if (exposedRefs.size > 0) {
|
||||
packagedByImage = packageExposureContextsByImage(
|
||||
buildImageExposureContextRows({
|
||||
nodeId: req.nodeId,
|
||||
exposures: parsed,
|
||||
qualifyingImageRefs: exposedRefs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Security] Failed to load stack exposures for image summaries:', err);
|
||||
exposedMap = null;
|
||||
packagedByImage = null;
|
||||
}
|
||||
type EnrichedSummary = (typeof summaries)[string] & {
|
||||
publicly_exposed: boolean | null;
|
||||
} & Partial<PackagedImageExposureContexts>;
|
||||
const out: Record<string, EnrichedSummary> = {};
|
||||
for (const [key, summary] of Object.entries(summaries)) {
|
||||
const exposed = exposedMap?.get(summary.image_ref);
|
||||
const publicly_exposed = exposed === undefined ? null : exposed;
|
||||
const packaged = publicly_exposed === true
|
||||
? packagedByImage?.get(summary.image_ref)
|
||||
: undefined;
|
||||
out[key] = {
|
||||
...summary,
|
||||
publicly_exposed,
|
||||
...packaged,
|
||||
};
|
||||
}
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to fetch image summaries:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch image summaries' });
|
||||
@@ -772,12 +844,18 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
else critHighByImage.set(f.image_ref, [f]);
|
||||
}
|
||||
let fixableCriticalHigh = 0;
|
||||
let residualCriticalHigh = 0;
|
||||
let accepted = 0;
|
||||
let notAffected = 0;
|
||||
let needsReview = 0;
|
||||
const remediationByImage = new Map<string, number>();
|
||||
const packageFixByImage = new Map<string, string[]>();
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.triage_status === 'needs_review') needsReview += 1;
|
||||
if (countsTowardResidualCriticalHigh(e)) {
|
||||
residualCriticalHigh += 1;
|
||||
}
|
||||
if (e.suppressed) {
|
||||
// A dismissing decision: not_affected is its own fact, the rest are "accepted".
|
||||
if (e.triage_status === 'not_affected') notAffected += 1;
|
||||
@@ -785,10 +863,41 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
continue;
|
||||
}
|
||||
// Not dismissed (no decision, needs_review, or affected): still actionable.
|
||||
if (e.fixed_version) fixableCriticalHigh += 1;
|
||||
if (e.fixed_version) {
|
||||
fixableCriticalHigh += 1;
|
||||
remediationByImage.set(imageRef, (remediationByImage.get(imageRef) ?? 0) + 1);
|
||||
const vids = packageFixByImage.get(imageRef);
|
||||
if (vids) vids.push(e.vulnerability_id);
|
||||
else packageFixByImage.set(imageRef, [e.vulnerability_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remediationFindings: RemediationFindingInput[] = [];
|
||||
for (const [image_ref, count] of remediationByImage) {
|
||||
remediationFindings.push({ image_ref, count });
|
||||
}
|
||||
const imageUpdateSvc = ImageUpdateService.getInstance();
|
||||
const remediation = classifyImageRemediation({
|
||||
findings: remediationFindings,
|
||||
details: db.getStackUpdateDetail(req.nodeId),
|
||||
checksEnabled: ImageUpdateService.isChecksEnabled(),
|
||||
freshnessWindowMs: imageUpdateSvc.getRemediationFreshnessWindowMs(),
|
||||
now: Date.now(),
|
||||
});
|
||||
const updateAvailableDrivers = collectPackageFixDrivers(
|
||||
packageFixByImage,
|
||||
remediation.imageRefsUpdateAvailable,
|
||||
);
|
||||
const waitingUpstreamDrivers = collectPackageFixDrivers(
|
||||
packageFixByImage,
|
||||
remediation.imageRefsWaitingUpstream,
|
||||
);
|
||||
const updateUnknownDrivers = collectPackageFixDrivers(
|
||||
packageFixByImage,
|
||||
remediation.imageRefsUpdateUnknown,
|
||||
);
|
||||
|
||||
// A known-exploited (KEV) finding gates a deploy at ANY severity, so the
|
||||
// posture fact counts non-suppressed KEV findings across all severities, not
|
||||
// just Critical/High. Sourced from its own latest-scan query so a Low/Medium
|
||||
@@ -801,11 +910,19 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
else kevByImage.set(f.image_ref, [f]);
|
||||
}
|
||||
let knownExploited = 0;
|
||||
const knownExploitedTargets: string[] = [];
|
||||
const kevDriverRows: Array<{ imageRef: string; vulnerability_id: string }> = [];
|
||||
for (const [imageRef, group] of kevByImage) {
|
||||
let imageHasKev = false;
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (!e.suppressed) knownExploited += 1;
|
||||
if (e.suppressed) continue;
|
||||
knownExploited += 1;
|
||||
imageHasKev = true;
|
||||
kevDriverRows.push({ imageRef, vulnerability_id: e.vulnerability_id });
|
||||
}
|
||||
if (imageHasKev) knownExploitedTargets.push(imageRef);
|
||||
}
|
||||
const knownExploitedDrivers = collectKevDrivers(kevDriverRows);
|
||||
|
||||
const acks = db.getMisconfigAcknowledgements();
|
||||
const highMisconfigs = db.getLatestHighMisconfigFindingsForNode(req.nodeId);
|
||||
@@ -822,61 +939,114 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
}
|
||||
|
||||
// Count distinct images that are publicly exposed AND have at least one
|
||||
// non-suppressed Critical/High finding. The exposure descriptor is cached
|
||||
// at deploy/update time, so this is O(stacks) + O(images), zero subprocess.
|
||||
// Network-exposed image classification from cached Compose descriptors.
|
||||
// Intentional exposure is risk context, never an independent Action-needed
|
||||
// blocker. Package fixed_version alone cannot manufacture exposed blockers.
|
||||
const exposures = db.getStackExposures(req.nodeId);
|
||||
const exposedMap = buildExposedImageMap(
|
||||
exposures.map((r) => {
|
||||
try { return JSON.parse(r.descriptor); } catch { return null; }
|
||||
}).filter(Boolean),
|
||||
);
|
||||
let publiclyExposed = 0;
|
||||
let exposedBlocker = 0;
|
||||
let exposedReview = 0;
|
||||
const parsedExposures: StackExposure[] = exposures.map((r) => {
|
||||
try { return JSON.parse(r.descriptor) as StackExposure; } catch { return null; }
|
||||
}).filter((e): e is StackExposure => e !== null);
|
||||
const exposedMap = buildExposedImageMap(parsedExposures);
|
||||
|
||||
const unsuppressedByImage = new Map<string, Array<{ vulnerability_id: string }>>();
|
||||
const exposedImageRefs = new Set<string>();
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
if (exposedMap.get(imageRef) !== true) continue;
|
||||
publiclyExposed += 1;
|
||||
let hasUnsuppressedFinding = false;
|
||||
let hasKevOrFixOrHighEpss = false;
|
||||
exposedImageRefs.add(imageRef);
|
||||
const kept: Array<{ vulnerability_id: string }> = [];
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.suppressed) continue;
|
||||
hasUnsuppressedFinding = true;
|
||||
if (
|
||||
e.fixed_version
|
||||
|| intel.get(e.vulnerability_id)?.kev
|
||||
|| (intel.get(e.vulnerability_id)?.epssScore ?? 0) >= HIGH_EPSS_THRESHOLD
|
||||
) {
|
||||
hasKevOrFixOrHighEpss = true;
|
||||
break;
|
||||
}
|
||||
if (!e.suppressed) kept.push({ vulnerability_id: e.vulnerability_id });
|
||||
}
|
||||
if (!hasUnsuppressedFinding) continue; // fully dismissed
|
||||
if (hasKevOrFixOrHighEpss) exposedBlocker += 1;
|
||||
else exposedReview += 1;
|
||||
unsuppressedByImage.set(imageRef, kept);
|
||||
}
|
||||
|
||||
const allExposedTargets = buildSecurityExposureTargets({
|
||||
nodeId: req.nodeId,
|
||||
exposures: parsedExposures,
|
||||
qualifyingImageRefs: exposedImageRefs,
|
||||
});
|
||||
const targetsByImage = new Map<string, PostureTarget[]>();
|
||||
for (const t of allExposedTargets) {
|
||||
const group = targetsByImage.get(t.imageRef);
|
||||
if (group) group.push(t);
|
||||
else targetsByImage.set(t.imageRef, [t]);
|
||||
}
|
||||
|
||||
const {
|
||||
publiclyExposed,
|
||||
exposureIntentConflict,
|
||||
exposureIntentConflictTargets,
|
||||
exposedUnclassified,
|
||||
exposedUnclassifiedTargets,
|
||||
elevatedExploitRisk,
|
||||
elevatedExploitRiskTargets,
|
||||
elevatedExploitRiskDrivers,
|
||||
elevatedExploitRiskDriverCount,
|
||||
elevatedExploitRiskDriversTruncated,
|
||||
} = classifyExposedImages({
|
||||
critHighByImage,
|
||||
exposedMap,
|
||||
targetsByImage,
|
||||
unsuppressedByImage,
|
||||
intel,
|
||||
});
|
||||
|
||||
const failedScans = db.countScansByStatus(req.nodeId, 'failed');
|
||||
|
||||
const postureFacts: SecurityPostureFacts = {
|
||||
scannerAvailable: svc.isTrivyAvailable(),
|
||||
hasCompletedScan: lastSuccessfulScanAt !== null,
|
||||
fixableCriticalHigh,
|
||||
fixableWithImageUpdate: remediation.fixableWithImageUpdate,
|
||||
fixableWaitingUpstream: remediation.fixableWaitingUpstream,
|
||||
fixableUpdateUnknown: remediation.fixableUpdateUnknown,
|
||||
updateChecksDisabled: remediation.updateChecksDisabled,
|
||||
secrets,
|
||||
dangerousCompose,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
exposedBlocker,
|
||||
exposedReview,
|
||||
exposureIntentConflict,
|
||||
exposedUnclassified,
|
||||
elevatedExploitRisk,
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
residualCriticalHigh,
|
||||
staleScans,
|
||||
failedScans,
|
||||
needsReview,
|
||||
fixableWithImageUpdateTargets: remediation.imageRefsUpdateAvailable,
|
||||
fixableWaitingUpstreamTargets: remediation.imageRefsWaitingUpstream,
|
||||
fixableUpdateUnknownTargets: remediation.imageRefsUpdateUnknown,
|
||||
knownExploitedTargets,
|
||||
knownExploitedDrivers: knownExploitedDrivers.drivers,
|
||||
knownExploitedDriverCount: knownExploitedDrivers.driverCount,
|
||||
knownExploitedDriversTruncated: knownExploitedDrivers.driversTruncated,
|
||||
exposureIntentConflictTargets,
|
||||
exposedUnclassifiedTargets,
|
||||
elevatedExploitRiskTargets,
|
||||
elevatedExploitRiskDrivers,
|
||||
elevatedExploitRiskDriverCount,
|
||||
elevatedExploitRiskDriversTruncated,
|
||||
fixableWithImageUpdateDrivers: updateAvailableDrivers.drivers,
|
||||
fixableWithImageUpdateDriverCount: updateAvailableDrivers.driverCount,
|
||||
fixableWithImageUpdateDriversTruncated: updateAvailableDrivers.driversTruncated,
|
||||
fixableWaitingUpstreamDrivers: waitingUpstreamDrivers.drivers,
|
||||
fixableWaitingUpstreamDriverCount: waitingUpstreamDrivers.driverCount,
|
||||
fixableWaitingUpstreamDriversTruncated: waitingUpstreamDrivers.driversTruncated,
|
||||
fixableUpdateUnknownDrivers: updateUnknownDrivers.drivers,
|
||||
fixableUpdateUnknownDriverCount: updateUnknownDrivers.driverCount,
|
||||
fixableUpdateUnknownDriversTruncated: updateUnknownDrivers.driversTruncated,
|
||||
};
|
||||
const posture = deriveSecurityPosture(postureFacts);
|
||||
const { reasons: postureReasons, primaryAction } = derivePostureReasons(postureFacts);
|
||||
const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed;
|
||||
const { reasons: postureReasons, primaryAction, targetsTruncated } = derivePostureReasons(postureFacts);
|
||||
// Legacy mixed-unit sum of blocker counts (not a distinct product metric).
|
||||
// Prefer posture / postureReasons. Intentional exposure alone is excluded.
|
||||
const actionable = remediation.fixableWithImageUpdate
|
||||
+ secrets
|
||||
+ dangerousCompose
|
||||
+ knownExploited
|
||||
+ elevatedExploitRisk
|
||||
+ exposureIntentConflict;
|
||||
|
||||
const overview: SecurityOverviewResponse = {
|
||||
scannedImages,
|
||||
@@ -913,9 +1083,10 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
notAffected,
|
||||
actionable,
|
||||
posture,
|
||||
posturePartial: critHigh.truncated || kevFindings.truncated || highMisconfigs.truncated,
|
||||
posturePartial: critHigh.truncated || kevFindings.truncated || highMisconfigs.truncated || targetsTruncated,
|
||||
postureReasons,
|
||||
primaryAction,
|
||||
updateChecksDisabled: remediation.updateChecksDisabled,
|
||||
};
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CryptoService } from './CryptoService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk';
|
||||
import { applySuppressions } from '../utils/suppression-filter';
|
||||
import { SENCHO_ROLLBACK_HOLD_SQL_LIKE } from '../utils/senchoRollbackHold';
|
||||
import type { AuditStatsInput } from './AuditAnomalyService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
|
||||
import { HIGH_EPSS_THRESHOLD } from './securityPosture';
|
||||
@@ -7101,7 +7102,8 @@ export class DatabaseService {
|
||||
) lv ON lv.image_ref = v.image_ref AND lv.max_scanned = v.scanned_at
|
||||
WHERE v.node_id = ? AND v.status = 'completed' AND v.scanners_used IN (${placeholders})
|
||||
) vuln ON vuln.image_ref = base.image_ref
|
||||
WHERE base.node_id = ? AND base.status = 'completed'`,
|
||||
WHERE base.node_id = ? AND base.status = 'completed'
|
||||
AND base.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'`,
|
||||
)
|
||||
.all(nodeId, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId) as Array<{
|
||||
image_ref: string;
|
||||
@@ -7165,9 +7167,11 @@ export class DatabaseService {
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
AND vd.severity IN ('CRITICAL', 'HIGH')
|
||||
LIMIT ?`,
|
||||
)
|
||||
@@ -7207,9 +7211,11 @@ export class DatabaseService {
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
|
||||
@@ -7288,9 +7294,11 @@ export class DatabaseService {
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1)
|
||||
-- Rank by the same exploitation-risk tiers the overview list
|
||||
-- displays (SecurityCharts exploitTier, "assume it's automatable"):
|
||||
@@ -7450,6 +7458,7 @@ export class DatabaseService {
|
||||
) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanned_at >= ?
|
||||
AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
|
||||
)
|
||||
SELECT day,
|
||||
SUM(critical_count) AS critical,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { isFullySyntheticHoldImage } from '../utils/senchoRollbackHold';
|
||||
import { isValidDockerNetworkName } from './network/dockerNetworkName';
|
||||
|
||||
export type {
|
||||
@@ -619,13 +620,9 @@ class DockerController {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Only hide an image from the generic inventory when every visible tag is
|
||||
// a synthetic sencho-rb hold tag; an image that also carries a normal
|
||||
// registry tag stays visible here (with the badge below) so the generic
|
||||
// inventory stays complete. Its generation still surfaces in the Rollback tab.
|
||||
const isFullySyntheticHoldImage = (repoTags: string[]): boolean =>
|
||||
repoTags.length > 0 && repoTags.every((tag) => tag.startsWith('sencho-rb/'));
|
||||
|
||||
// Hide hold-only images from generic inventory. Dual-tagged images (registry
|
||||
// tag + hold) stay visible here with the badge below; generations still
|
||||
// surface in the Rollback tab.
|
||||
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages)
|
||||
.map((img: any) => {
|
||||
const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
@@ -778,6 +778,44 @@ export class ImageUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly MIN_REMEDIATION_FRESHNESS_MS = 30 * 60 * 1000;
|
||||
private static readonly MAX_REMEDIATION_FRESHNESS_MS = 48 * 60 * 60 * 1000;
|
||||
private static readonly CRON_CADENCE_FALLBACK_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Freshness window for Security remediation classification: 2× the
|
||||
* expected check cadence. Interval mode uses the live intervalMs. Cron
|
||||
* mode uses the gap between two successive fire times (not the unused
|
||||
* interval setting). Missing/unparseable cron falls back to 24h. Clamped
|
||||
* to [30m, 48h].
|
||||
*/
|
||||
public getRemediationFreshnessWindowMs(): number {
|
||||
let cadenceMs = this.intervalMs;
|
||||
if (this.mode === 'cron') {
|
||||
// Default to 24h; override only when two successive fire times yield a positive gap.
|
||||
cadenceMs = ImageUpdateService.CRON_CADENCE_FALLBACK_MS;
|
||||
if (this.cronExpression) {
|
||||
try {
|
||||
const expr = CronExpressionParser.parse(this.cronExpression);
|
||||
const first = expr.next().toDate().getTime();
|
||||
const second = expr.next().toDate().getTime();
|
||||
const gap = second - first;
|
||||
if (gap > 0) cadenceMs = gap;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[ImageUpdateService] Could not derive cron cadence for remediation freshness; using 24h fallback:',
|
||||
getErrorMessage(e, String(e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const windowMs = 2 * cadenceMs;
|
||||
return Math.min(
|
||||
ImageUpdateService.MAX_REMEDIATION_FRESHNESS_MS,
|
||||
Math.max(ImageUpdateService.MIN_REMEDIATION_FRESHNESS_MS, windowMs),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Core check ──────────────────────────────────────────────────────────
|
||||
|
||||
private async check() {
|
||||
@@ -1230,11 +1268,14 @@ export class ImageUpdateService {
|
||||
* - If memory generation advanced after observation, abort (stale).
|
||||
* - If memory generation still equals the observation watermark, advance
|
||||
* (tombstone) so an equal-generation writer reserved before observation
|
||||
* cannot commit after the clear (SF-4).
|
||||
* cannot commit after the clear.
|
||||
* - If the persisted row generation advanced after observation, keep the row.
|
||||
* - Otherwise delete partial, failed, and confirmed ok+true rows.
|
||||
* - If the row is already ok with no update, keep it. Deleting that row
|
||||
* would make Security treat a confirmed no-update as unknown.
|
||||
* - Otherwise delete partial, failed, and ok+true rows.
|
||||
*
|
||||
* Returns cleared | stale | absent.
|
||||
* Returns cleared (row deleted), stale (memory generation raced), or
|
||||
* absent (no row, generation advanced, or the row is already ok+false).
|
||||
*/
|
||||
public async commitPreviewClear(
|
||||
nodeId: number,
|
||||
@@ -1270,6 +1311,7 @@ export class ImageUpdateService {
|
||||
// pre-preview snapshot). Memory peek resets on restart; SQLite does not.
|
||||
const rowGeneration = db.getStackUpdateWriteGeneration(nodeId, stackName);
|
||||
if (rowGeneration > observedRowGeneration) return;
|
||||
if (detail.checkStatus === 'ok' && !detail.hasUpdate) return;
|
||||
deleted = db.clearStackUpdateStatus(nodeId, stackName);
|
||||
});
|
||||
if (!committed) return 'stale';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FleetSyncService } from './FleetSyncService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { SEVERITY_ORDER } from '../utils/severity';
|
||||
import { isSenchoRollbackHoldRef } from '../utils/senchoRollbackHold';
|
||||
import type { PolicyBlockReason } from '../utils/policy-risk';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -621,6 +622,9 @@ class TrivyService {
|
||||
scanners?: readonly TrivyScanner[];
|
||||
} = {},
|
||||
): Promise<TrivyScanResult> {
|
||||
if (isSenchoRollbackHoldRef(imageRef)) {
|
||||
throw new Error('Sencho rollback-hold images are recovery state and cannot be scanned');
|
||||
}
|
||||
const binary = this.binaryPath;
|
||||
if (!binary) {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
@@ -1171,7 +1175,9 @@ class TrivyService {
|
||||
const imageRefs = new Set<string>();
|
||||
for (const img of images as Array<{ RepoTags?: string[] }>) {
|
||||
for (const tag of img.RepoTags ?? []) {
|
||||
if (tag && tag !== '<none>:<none>') imageRefs.add(tag);
|
||||
// Skip hold tags; dual-tagged images are scanned via the registry tag.
|
||||
if (!tag || tag === '<none>:<none>' || isSenchoRollbackHoldRef(tag)) continue;
|
||||
imageRefs.add(tag);
|
||||
}
|
||||
}
|
||||
const refs = Array.from(imageRefs);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Classifies network-exposed Crit/High images into posture buckets.
|
||||
*
|
||||
* Exposure fact (Compose beyond loopback) is separate from exposure correctness
|
||||
* (intent match) and from Security consequence (KEV, elevated EPSS, confirmed
|
||||
* image update). Intentional exposure never independently manufactures an
|
||||
* Action-needed exposure blocker. Package fixed_version alone never recreates
|
||||
* that blocker through this path.
|
||||
*/
|
||||
import { HIGH_EPSS_THRESHOLD, type PostureDriverFinding, type PostureTarget } from './securityPosture';
|
||||
|
||||
/** Bounded driver findings attached to vulnerability-derived posture reasons. */
|
||||
export const POSTURE_DRIVER_CAP = 50;
|
||||
|
||||
export interface CappedDrivers {
|
||||
drivers: PostureDriverFinding[];
|
||||
/** Full contributing count before the display cap. */
|
||||
driverCount: number;
|
||||
driversTruncated: boolean;
|
||||
}
|
||||
|
||||
export function capDriverFindings(drivers: PostureDriverFinding[]): CappedDrivers {
|
||||
return {
|
||||
drivers: drivers.slice(0, POSTURE_DRIVER_CAP),
|
||||
driverCount: drivers.length,
|
||||
driversTruncated: drivers.length > POSTURE_DRIVER_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
export type CveIntelLookup = Map<string, { kev?: boolean; epssScore?: number | null }>;
|
||||
|
||||
export interface ExposedFindingRow {
|
||||
vulnerability_id: string;
|
||||
/** Present when the finding survived suppression filtering as actionable. */
|
||||
suppressed?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassifyExposedImagesInput {
|
||||
/** Crit/High findings keyed by image_ref (raw, before suppression). */
|
||||
critHighByImage: Map<string, ExposedFindingRow[]>;
|
||||
/** image_ref → true when Compose declares beyond-loopback / host-network. */
|
||||
exposedMap: Map<string, boolean>;
|
||||
/** Per-image exposure targets already enriched with Networking intent. */
|
||||
targetsByImage: Map<string, PostureTarget[]>;
|
||||
/** Unsuppressed findings per image (caller applies applySuppressions). */
|
||||
unsuppressedByImage: Map<string, ExposedFindingRow[]>;
|
||||
intel: CveIntelLookup;
|
||||
}
|
||||
|
||||
export interface ClassifyExposedImagesResult {
|
||||
/** Distinct exposed images in the Crit/High index (incl. fully suppressed). */
|
||||
publiclyExposed: number;
|
||||
/** Intent mismatch (internal/same-node while exposed) with unsuppressed Crit/High. */
|
||||
exposureIntentConflict: number;
|
||||
exposureIntentConflictTargets: PostureTarget[];
|
||||
/** Intent unset/unavailable (not intentional) with unsuppressed Crit/High. Review only. */
|
||||
exposedUnclassified: number;
|
||||
exposedUnclassifiedTargets: PostureTarget[];
|
||||
/**
|
||||
* Network-exposed images with unsuppressed elevated-EPSS Crit/High.
|
||||
* Independent of intentional vs unclassified; never uses fixed_version alone.
|
||||
*/
|
||||
elevatedExploitRisk: number;
|
||||
elevatedExploitRiskTargets: PostureTarget[];
|
||||
elevatedExploitRiskDrivers: PostureDriverFinding[];
|
||||
elevatedExploitRiskDriverCount: number;
|
||||
elevatedExploitRiskDriversTruncated: boolean;
|
||||
}
|
||||
|
||||
function isIntentionalTarget(t: PostureTarget): boolean {
|
||||
return (
|
||||
t.intentStatus === 'set'
|
||||
&& !t.intentConflict
|
||||
&& (t.exposureIntent === 'public'
|
||||
|| t.exposureIntent === 'lan'
|
||||
|| t.exposureIntent === 'reverse-proxy'
|
||||
|| t.exposureIntent === 'temporary')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket an image's exposure contexts.
|
||||
* Conflict wins. Any unset (or only-unavailable / non-intentional) is unclassified.
|
||||
* All complete contexts intentional → intentional.
|
||||
*/
|
||||
export function classifyImageExposureBucket(
|
||||
targets: PostureTarget[],
|
||||
): 'conflict' | 'intentional' | 'unclassified' {
|
||||
if (targets.some((t) => t.intentConflict)) return 'conflict';
|
||||
if (targets.length === 0) return 'unclassified';
|
||||
if (targets.some((t) => t.intentStatus === 'unset')) return 'unclassified';
|
||||
|
||||
const complete = targets.filter((t) => t.intentStatus !== 'unavailable');
|
||||
if (complete.length === 0) return 'unclassified';
|
||||
if (complete.every(isIntentionalTarget)) return 'intentional';
|
||||
return 'unclassified';
|
||||
}
|
||||
|
||||
function targetKey(t: PostureTarget): string {
|
||||
return `${t.imageRef}\0${t.stackName ?? ''}\0${t.serviceName ?? ''}`;
|
||||
}
|
||||
|
||||
function pushUniqueTargets(into: PostureTarget[], rows: PostureTarget[]): void {
|
||||
const seen = new Set(into.map(targetKey));
|
||||
for (const t of rows) {
|
||||
const key = targetKey(t);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
into.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split exposed Crit/High images into conflict / unclassified review / elevated EPSS.
|
||||
* Intentional exposure contributes only via elevated EPSS (or other independent
|
||||
* drivers computed outside this function: KEV, confirmed image update).
|
||||
*/
|
||||
export function classifyExposedImages(input: ClassifyExposedImagesInput): ClassifyExposedImagesResult {
|
||||
const {
|
||||
critHighByImage,
|
||||
exposedMap,
|
||||
targetsByImage,
|
||||
unsuppressedByImage,
|
||||
intel,
|
||||
} = input;
|
||||
|
||||
let publiclyExposed = 0;
|
||||
const conflictTargets: PostureTarget[] = [];
|
||||
const unclassifiedTargets: PostureTarget[] = [];
|
||||
const elevatedTargets: PostureTarget[] = [];
|
||||
const elevatedDrivers: PostureDriverFinding[] = [];
|
||||
const conflictImages = new Set<string>();
|
||||
const unclassifiedImages = new Set<string>();
|
||||
const elevatedImages = new Set<string>();
|
||||
|
||||
for (const imageRef of critHighByImage.keys()) {
|
||||
if (exposedMap.get(imageRef) !== true) continue;
|
||||
publiclyExposed += 1;
|
||||
|
||||
const unsuppressed = unsuppressedByImage.get(imageRef) ?? [];
|
||||
if (unsuppressed.length === 0) continue;
|
||||
|
||||
const targets = targetsByImage.get(imageRef) ?? [{ imageRef }];
|
||||
const bucket = classifyImageExposureBucket(targets);
|
||||
|
||||
if (bucket === 'conflict') {
|
||||
conflictImages.add(imageRef);
|
||||
pushUniqueTargets(conflictTargets, targets);
|
||||
} else if (bucket === 'unclassified') {
|
||||
unclassifiedImages.add(imageRef);
|
||||
pushUniqueTargets(unclassifiedTargets, targets);
|
||||
}
|
||||
|
||||
let hasHighEpss = false;
|
||||
for (const e of unsuppressed) {
|
||||
const epss = intel.get(e.vulnerability_id)?.epssScore ?? 0;
|
||||
if (epss < HIGH_EPSS_THRESHOLD) continue;
|
||||
hasHighEpss = true;
|
||||
elevatedDrivers.push({ vulnerabilityId: e.vulnerability_id, imageRef });
|
||||
}
|
||||
if (hasHighEpss) {
|
||||
elevatedImages.add(imageRef);
|
||||
pushUniqueTargets(elevatedTargets, targets);
|
||||
}
|
||||
}
|
||||
|
||||
const elevated = capDriverFindings(elevatedDrivers);
|
||||
return {
|
||||
publiclyExposed,
|
||||
exposureIntentConflict: conflictImages.size,
|
||||
exposureIntentConflictTargets: conflictTargets,
|
||||
exposedUnclassified: unclassifiedImages.size,
|
||||
exposedUnclassifiedTargets: unclassifiedTargets,
|
||||
elevatedExploitRisk: elevatedImages.size,
|
||||
elevatedExploitRiskTargets: elevatedTargets,
|
||||
elevatedExploitRiskDrivers: elevated.drivers,
|
||||
elevatedExploitRiskDriverCount: elevated.driverCount,
|
||||
elevatedExploitRiskDriversTruncated: elevated.driversTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cap unsuppressed KEV driver rows for posture attachment. */
|
||||
export function collectKevDrivers(
|
||||
drivers: Array<{ imageRef: string; vulnerability_id: string; suppressed?: boolean }>,
|
||||
): CappedDrivers {
|
||||
const out: PostureDriverFinding[] = [];
|
||||
for (const e of drivers) {
|
||||
if (e.suppressed) continue;
|
||||
out.push({ vulnerabilityId: e.vulnerability_id, imageRef: e.imageRef });
|
||||
}
|
||||
return capDriverFindings(out);
|
||||
}
|
||||
|
||||
/** Package-fix finding drivers for the given image refs (order preserved per image list). */
|
||||
export function collectPackageFixDrivers(
|
||||
packageFixByImage: Map<string, string[]>,
|
||||
imageRefs: string[],
|
||||
): CappedDrivers {
|
||||
const out: PostureDriverFinding[] = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
for (const vulnerabilityId of packageFixByImage.get(imageRef) ?? []) {
|
||||
out.push({ vulnerabilityId, imageRef });
|
||||
}
|
||||
}
|
||||
return capDriverFindings(out);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Shared Security exposure-context builder: cached StackExposure descriptors
|
||||
* plus Networking getExposureContext. Used by overview posture targets and
|
||||
* standing /image-summaries enrichment.
|
||||
*
|
||||
* Unavailable context is distinct from unset intent. Absolute all-intentional
|
||||
* claims require every context to be complete; unavailable never becomes certainty.
|
||||
*/
|
||||
import { getExposureContext } from './network/exposureContext';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
import type { StackExposure } from './preflight/exposure';
|
||||
import type { PostureTarget } from './securityPosture';
|
||||
|
||||
/** Max contexts returned per image on /image-summaries (display list only). */
|
||||
export const IMAGE_EXPOSURE_CONTEXT_CAP = 20;
|
||||
|
||||
/** Intents that mean the operator deliberately classified exposure. */
|
||||
const INTENTIONAL: ReadonlySet<ExposureIntent> = new Set([
|
||||
'public', 'lan', 'reverse-proxy', 'temporary',
|
||||
]);
|
||||
|
||||
const CONFLICT: ReadonlySet<ExposureIntent> = new Set(['internal', 'same-node']);
|
||||
|
||||
/** Per stack/service exposure + intent (image identity lives on the parent). */
|
||||
export interface ImageExposureContext {
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
exposureReason: 'published-port' | 'host-network' | null;
|
||||
exposureIntent?: ExposureIntent;
|
||||
intentStatus: 'set' | 'unset' | 'unavailable';
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageExposureContextSummary {
|
||||
hasConflict: boolean;
|
||||
hasUnclassified: boolean;
|
||||
hasUnavailable: boolean;
|
||||
/** Every complete (set) context is intentional and there is no unset among available. */
|
||||
allKnownIntentional: boolean;
|
||||
}
|
||||
|
||||
export interface PackagedImageExposureContexts {
|
||||
exposure_contexts: ImageExposureContext[];
|
||||
exposure_context_count: number;
|
||||
exposure_contexts_truncated: boolean;
|
||||
exposure_context_summary: ImageExposureContextSummary;
|
||||
}
|
||||
|
||||
export interface BuildImageExposureContextsInput {
|
||||
nodeId: number;
|
||||
exposures: StackExposure[];
|
||||
/** When set, only services whose image is in the set are emitted. */
|
||||
qualifyingImageRefs?: Set<string>;
|
||||
getContext?: typeof getExposureContext;
|
||||
}
|
||||
|
||||
/** Row with imageRef for grouping into per-image packages / posture targets. */
|
||||
export interface ImageExposureContextRow extends ImageExposureContext {
|
||||
imageRef: string;
|
||||
}
|
||||
|
||||
function effectiveIntent(
|
||||
service: string,
|
||||
stackIntent: ExposureIntent | null,
|
||||
serviceIntents: Record<string, ExposureIntent>,
|
||||
): ExposureIntent | null {
|
||||
return serviceIntents[service] ?? stackIntent ?? null;
|
||||
}
|
||||
|
||||
function rowKey(imageRef: string, stackName: string, serviceName: string): string {
|
||||
return `${imageRef}\0${stackName}\0${serviceName}`;
|
||||
}
|
||||
|
||||
function isIntentionalSet(c: Pick<ImageExposureContext, 'intentStatus' | 'exposureIntent' | 'intentConflict'>): boolean {
|
||||
return (
|
||||
c.intentStatus === 'set'
|
||||
&& !c.intentConflict
|
||||
&& !!c.exposureIntent
|
||||
&& INTENTIONAL.has(c.exposureIntent)
|
||||
);
|
||||
}
|
||||
|
||||
/** Sort: conflict, unset, unavailable, then intentional/set. */
|
||||
export function sortExposureContexts<T extends ImageExposureContext>(contexts: T[]): T[] {
|
||||
return [...contexts].sort((a, b) => exposureContextRank(a) - exposureContextRank(b));
|
||||
}
|
||||
|
||||
export function exposureContextRank(c: ImageExposureContext): number {
|
||||
if (c.intentConflict) return 0;
|
||||
if (c.intentStatus === 'unset') return 1;
|
||||
if (c.intentStatus === 'unavailable') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function summarizeExposureContexts(
|
||||
contexts: ImageExposureContext[],
|
||||
): ImageExposureContextSummary {
|
||||
let hasConflict = false;
|
||||
let hasUnclassified = false;
|
||||
let hasUnavailable = false;
|
||||
let sawComplete = false;
|
||||
let allKnownIntentional = true;
|
||||
|
||||
for (const c of contexts) {
|
||||
if (c.intentConflict) hasConflict = true;
|
||||
if (c.intentStatus === 'unset') hasUnclassified = true;
|
||||
if (c.intentStatus === 'unavailable') {
|
||||
hasUnavailable = true;
|
||||
continue;
|
||||
}
|
||||
sawComplete = true;
|
||||
if (!isIntentionalSet(c)) allKnownIntentional = false;
|
||||
}
|
||||
|
||||
if (!sawComplete) allKnownIntentional = false;
|
||||
|
||||
return { hasConflict, hasUnclassified, hasUnavailable, allKnownIntentional };
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute intentional: every context is set, non-conflicting, intentional.
|
||||
* Unavailable or unset anywhere yields false. Empty yields false.
|
||||
*/
|
||||
export function allContextsAbsolutelyIntentional(
|
||||
contexts: ImageExposureContext[] | undefined,
|
||||
truncated = false,
|
||||
): boolean {
|
||||
if (truncated || !contexts || contexts.length === 0) return false;
|
||||
return contexts.every(isIntentionalSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial intentional: at least one unavailable, no conflict/unset among
|
||||
* available contexts, and every available set context is intentional.
|
||||
*/
|
||||
export function partialIntentionalWithUnavailable(
|
||||
contexts: ImageExposureContext[] | undefined,
|
||||
truncated = false,
|
||||
): { partial: boolean; unavailableCount: number } {
|
||||
if (truncated || !contexts || contexts.length === 0) {
|
||||
return { partial: false, unavailableCount: 0 };
|
||||
}
|
||||
let unavailableCount = 0;
|
||||
let sawAvailable = false;
|
||||
for (const c of contexts) {
|
||||
if (c.intentStatus === 'unavailable') {
|
||||
unavailableCount += 1;
|
||||
continue;
|
||||
}
|
||||
sawAvailable = true;
|
||||
if (!isIntentionalSet(c)) {
|
||||
return { partial: false, unavailableCount };
|
||||
}
|
||||
}
|
||||
return {
|
||||
partial: unavailableCount > 0 && sawAvailable,
|
||||
unavailableCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function packageImageExposureContexts(
|
||||
contexts: ImageExposureContext[],
|
||||
cap = IMAGE_EXPOSURE_CONTEXT_CAP,
|
||||
): PackagedImageExposureContexts {
|
||||
const summary = summarizeExposureContexts(contexts);
|
||||
const sorted = sortExposureContexts(contexts);
|
||||
const truncated = sorted.length > cap;
|
||||
return {
|
||||
exposure_contexts: sorted.slice(0, cap),
|
||||
exposure_context_count: contexts.length,
|
||||
exposure_contexts_truncated: truncated,
|
||||
exposure_context_summary: summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one context row per exposed service (optionally filtered by image set).
|
||||
* Batches getExposureContext once per distinct stack.
|
||||
*/
|
||||
export function buildImageExposureContextRows(
|
||||
input: BuildImageExposureContextsInput,
|
||||
): ImageExposureContextRow[] {
|
||||
const { nodeId, exposures, qualifyingImageRefs } = input;
|
||||
const getContext = input.getContext ?? getExposureContext;
|
||||
|
||||
if (qualifyingImageRefs && qualifyingImageRefs.size === 0) return [];
|
||||
|
||||
const contextByStack = new Map<string, ReturnType<typeof getExposureContext>>();
|
||||
const seen = new Set<string>();
|
||||
const rows: ImageExposureContextRow[] = [];
|
||||
|
||||
for (const exp of exposures) {
|
||||
for (const svc of exp.services) {
|
||||
if (!svc.image || !svc.publiclyExposed) continue;
|
||||
if (qualifyingImageRefs && !qualifyingImageRefs.has(svc.image)) continue;
|
||||
|
||||
let ctx = contextByStack.get(exp.stack);
|
||||
if (ctx === undefined) {
|
||||
ctx = getContext(nodeId, exp.stack);
|
||||
contextByStack.set(exp.stack, ctx);
|
||||
}
|
||||
|
||||
const row: ImageExposureContextRow = {
|
||||
imageRef: svc.image,
|
||||
stackName: exp.stack,
|
||||
serviceName: svc.service,
|
||||
exposureReason: svc.reason,
|
||||
intentStatus: 'unavailable',
|
||||
};
|
||||
|
||||
if (ctx.available) {
|
||||
const intent = effectiveIntent(svc.service, ctx.stackIntent, ctx.serviceIntents);
|
||||
if (intent === null || intent === 'unknown') {
|
||||
row.intentStatus = 'unset';
|
||||
} else {
|
||||
row.intentStatus = 'set';
|
||||
row.exposureIntent = intent;
|
||||
if (CONFLICT.has(intent)) row.intentConflict = true;
|
||||
}
|
||||
}
|
||||
|
||||
const key = rowKey(row.imageRef, row.stackName, row.serviceName);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Group rows by imageRef and package each for /image-summaries. */
|
||||
export function packageExposureContextsByImage(
|
||||
rows: ImageExposureContextRow[],
|
||||
cap = IMAGE_EXPOSURE_CONTEXT_CAP,
|
||||
): Map<string, PackagedImageExposureContexts> {
|
||||
const byImage = new Map<string, ImageExposureContext[]>();
|
||||
for (const row of rows) {
|
||||
const { imageRef, ...ctx } = row;
|
||||
const list = byImage.get(imageRef) ?? [];
|
||||
list.push(ctx);
|
||||
byImage.set(imageRef, list);
|
||||
}
|
||||
const out = new Map<string, PackagedImageExposureContexts>();
|
||||
for (const [imageRef, contexts] of byImage) {
|
||||
out.set(imageRef, packageImageExposureContexts(contexts, cap));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toPostureTarget(row: ImageExposureContextRow): PostureTarget {
|
||||
const target: PostureTarget = {
|
||||
imageRef: row.imageRef,
|
||||
stackName: row.stackName,
|
||||
serviceName: row.serviceName,
|
||||
exposureReason: row.exposureReason,
|
||||
intentStatus: row.intentStatus,
|
||||
};
|
||||
if (row.exposureIntent) target.exposureIntent = row.exposureIntent;
|
||||
if (row.intentConflict) target.intentConflict = true;
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit PostureTarget rows for qualifying images (overview).
|
||||
* Batches getExposureContext once per distinct stack.
|
||||
*/
|
||||
export function buildSecurityExposureTargets(
|
||||
input: BuildImageExposureContextsInput & { qualifyingImageRefs: Set<string> },
|
||||
): PostureTarget[] {
|
||||
return buildImageExposureContextRows(input).map(toPostureTarget);
|
||||
}
|
||||
|
||||
function contextsFromTargets(targets: PostureTarget[] | undefined): ImageExposureContext[] {
|
||||
if (!targets) return [];
|
||||
const out: ImageExposureContext[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.stackName || !t.serviceName || !t.intentStatus) continue;
|
||||
out.push({
|
||||
stackName: t.stackName,
|
||||
serviceName: t.serviceName,
|
||||
exposureReason: t.exposureReason ?? null,
|
||||
exposureIntent: t.exposureIntent,
|
||||
intentStatus: t.intentStatus,
|
||||
intentConflict: t.intentConflict,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Absolute intentional for posture targets (unavailable yields false). */
|
||||
export function allTargetsIntentionallyClassified(
|
||||
targets: PostureTarget[] | undefined,
|
||||
truncated = false,
|
||||
): boolean {
|
||||
return allContextsAbsolutelyIntentional(contextsFromTargets(targets), truncated);
|
||||
}
|
||||
|
||||
export function anyTargetIntentConflict(targets: PostureTarget[] | undefined): boolean {
|
||||
return targets?.some((t) => t.intentConflict) ?? false;
|
||||
}
|
||||
|
||||
export function anyTargetIntentUnset(targets: PostureTarget[] | undefined): boolean {
|
||||
return targets?.some((t) => t.intentStatus === 'unset') ?? false;
|
||||
}
|
||||
|
||||
export function anyTargetIntentUnavailable(targets: PostureTarget[] | undefined): boolean {
|
||||
return targets?.some((t) => t.intentStatus === 'unavailable') ?? false;
|
||||
}
|
||||
|
||||
export function partialTargetsIntentionalWithUnavailable(
|
||||
targets: PostureTarget[] | undefined,
|
||||
truncated = false,
|
||||
): { partial: boolean; unavailableCount: number } {
|
||||
return partialIntentionalWithUnavailable(contextsFromTargets(targets), truncated);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Join Security package-fix findings to persisted image-update evidence.
|
||||
*
|
||||
* Trivy `fixed_version` proves a patched package exists. It does not prove an
|
||||
* applicable container image update exists. This module consumes the canonical
|
||||
* ImageUpdateService rows only (no registry I/O) and classifies each finding
|
||||
* image as: confirmed update available, authoritative waiting-for-upstream, or
|
||||
* uncertain remediation availability.
|
||||
*
|
||||
* Classification may join via normalizeImageRef, but imageRefs* arrays always
|
||||
* carry the raw finding image_ref so Images-tab targeting matches scan summaries.
|
||||
*/
|
||||
import { normalizeImageRef } from './DriftDetectionService';
|
||||
import type { StackServiceStatus, StackUpdateDetail } from './DatabaseService';
|
||||
|
||||
export type ImageRemediationClass = 'update_available' | 'waiting_upstream' | 'uncertain';
|
||||
|
||||
export interface RemediationFindingInput {
|
||||
image_ref: string;
|
||||
/** Count of unsuppressed Crit/High findings with fixed_version on this image. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ImageRemediationFacts {
|
||||
fixableWithImageUpdate: number;
|
||||
fixableWaitingUpstream: number;
|
||||
fixableUpdateUnknown: number;
|
||||
/** True when checks are disabled and any fixable package findings exist. */
|
||||
updateChecksDisabled: boolean;
|
||||
/** Distinct raw finding image_refs in each class (never normalizeImageRef'd). */
|
||||
imageRefsUpdateAvailable: string[];
|
||||
imageRefsWaitingUpstream: string[];
|
||||
imageRefsUpdateUnknown: string[];
|
||||
}
|
||||
|
||||
export interface ClassifyImageRemediationInput {
|
||||
findings: RemediationFindingInput[];
|
||||
details: Record<string, StackUpdateDetail>;
|
||||
checksEnabled: boolean;
|
||||
freshnessWindowMs: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
interface IndexedService {
|
||||
stackName: string;
|
||||
service: StackServiceStatus;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
function collectServiceRefs(service: StackServiceStatus): string[] {
|
||||
const refs: string[] = [];
|
||||
if (service.image) refs.push(service.image);
|
||||
for (const runtime of service.runtimeImages ?? []) {
|
||||
if (runtime) refs.push(runtime);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** Build normalizeImageRef → services index from persisted stack update detail. */
|
||||
export function buildUpdateServiceIndex(
|
||||
details: Record<string, StackUpdateDetail>,
|
||||
): Map<string, IndexedService[]> {
|
||||
const index = new Map<string, IndexedService[]>();
|
||||
for (const [stackName, detail] of Object.entries(details)) {
|
||||
for (const service of detail.services ?? []) {
|
||||
for (const ref of collectServiceRefs(service)) {
|
||||
const key = normalizeImageRef(ref);
|
||||
if (!key) continue;
|
||||
const entry: IndexedService = { stackName, service, checkedAt: detail.checkedAt };
|
||||
const list = index.get(key);
|
||||
if (list) list.push(entry);
|
||||
else index.set(key, [entry]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function isStackFresh(checkedAt: number, now: number, freshnessWindowMs: number): boolean {
|
||||
if (!Number.isFinite(checkedAt) || checkedAt <= 0) return false;
|
||||
return now - checkedAt <= freshnessWindowMs;
|
||||
}
|
||||
|
||||
function classifyMatches(
|
||||
matches: IndexedService[],
|
||||
freshnessWindowMs: number,
|
||||
now: number,
|
||||
): ImageRemediationClass {
|
||||
if (matches.length === 0) return 'uncertain';
|
||||
|
||||
let sawCheckable = false;
|
||||
let anyConfirmedUpdate = false;
|
||||
let anyUncertain = false;
|
||||
let anyAuthoritativeNegative = false;
|
||||
|
||||
for (const { service, checkedAt } of matches) {
|
||||
if (service.checkStatus === 'not_checkable') continue;
|
||||
sawCheckable = true;
|
||||
if (service.checkStatus !== 'ok' || !isStackFresh(checkedAt, now, freshnessWindowMs)) {
|
||||
anyUncertain = true;
|
||||
continue;
|
||||
}
|
||||
if (service.hasUpdate) anyConfirmedUpdate = true;
|
||||
else anyAuthoritativeNegative = true;
|
||||
}
|
||||
|
||||
// Confirmed update on any stack wins over sibling partial/stale uncertainty.
|
||||
if (anyConfirmedUpdate) return 'update_available';
|
||||
if (anyUncertain || !sawCheckable) return 'uncertain';
|
||||
if (anyAuthoritativeNegative) return 'waiting_upstream';
|
||||
return 'uncertain';
|
||||
}
|
||||
|
||||
function emptyRemediationFacts(
|
||||
overrides: Partial<ImageRemediationFacts> = {},
|
||||
): ImageRemediationFacts {
|
||||
return {
|
||||
fixableWithImageUpdate: 0,
|
||||
fixableWaitingUpstream: 0,
|
||||
fixableUpdateUnknown: 0,
|
||||
updateChecksDisabled: false,
|
||||
imageRefsUpdateAvailable: [],
|
||||
imageRefsWaitingUpstream: [],
|
||||
imageRefsUpdateUnknown: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pushUnique(list: string[], ref: string): void {
|
||||
if (!list.includes(ref)) list.push(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify package-fix Crit/High findings against persisted update evidence.
|
||||
* Counts are finding counts (not distinct images). imageRefs* are distinct
|
||||
* raw finding image_refs in each class.
|
||||
*/
|
||||
export function classifyImageRemediation(input: ClassifyImageRemediationInput): ImageRemediationFacts {
|
||||
const { findings, details, checksEnabled, freshnessWindowMs, now } = input;
|
||||
|
||||
const totalFixable = findings.reduce((sum, f) => sum + f.count, 0);
|
||||
if (totalFixable === 0) return emptyRemediationFacts();
|
||||
if (!checksEnabled) {
|
||||
const imageRefsUpdateUnknown: string[] = [];
|
||||
for (const finding of findings) pushUnique(imageRefsUpdateUnknown, finding.image_ref);
|
||||
return emptyRemediationFacts({
|
||||
fixableUpdateUnknown: totalFixable,
|
||||
updateChecksDisabled: true,
|
||||
imageRefsUpdateUnknown,
|
||||
});
|
||||
}
|
||||
|
||||
let fixableWithImageUpdate = 0;
|
||||
let fixableWaitingUpstream = 0;
|
||||
let fixableUpdateUnknown = 0;
|
||||
const imageRefsUpdateAvailable: string[] = [];
|
||||
const imageRefsWaitingUpstream: string[] = [];
|
||||
const imageRefsUpdateUnknown: string[] = [];
|
||||
|
||||
const index = buildUpdateServiceIndex(details);
|
||||
for (const finding of findings) {
|
||||
const key = normalizeImageRef(finding.image_ref);
|
||||
const matches = key ? (index.get(key) ?? []) : [];
|
||||
switch (classifyMatches(matches, freshnessWindowMs, now)) {
|
||||
case 'update_available':
|
||||
fixableWithImageUpdate += finding.count;
|
||||
pushUnique(imageRefsUpdateAvailable, finding.image_ref);
|
||||
break;
|
||||
case 'waiting_upstream':
|
||||
fixableWaitingUpstream += finding.count;
|
||||
pushUnique(imageRefsWaitingUpstream, finding.image_ref);
|
||||
break;
|
||||
default:
|
||||
fixableUpdateUnknown += finding.count;
|
||||
pushUnique(imageRefsUpdateUnknown, finding.image_ref);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyRemediationFacts({
|
||||
fixableWithImageUpdate,
|
||||
fixableWaitingUpstream,
|
||||
fixableUpdateUnknown,
|
||||
imageRefsUpdateAvailable,
|
||||
imageRefsWaitingUpstream,
|
||||
imageRefsUpdateUnknown,
|
||||
});
|
||||
}
|
||||
@@ -11,16 +11,37 @@
|
||||
* changes never require a schema migration, and the same verdict can be reused
|
||||
* by other surfaces (action queue, per-stack blast radius).
|
||||
*
|
||||
* Posture is deliberately NOT raw severity: a page is never "Secure" merely
|
||||
* because counts are zero-weighted, and never "Action needed" merely because a
|
||||
* Critical exists with nothing to do about it. "Secure" means nothing is
|
||||
* actionable right now, not a claim that no vulnerabilities exist.
|
||||
* Posture is deliberately NOT raw severity: a page is never "Action needed"
|
||||
* merely because a Critical exists with nothing to do about it.
|
||||
*
|
||||
* Action needed: a concrete blocker Sencho can drive now.
|
||||
* Monitoring: no blocker, but residual Crit/High (including accepted/ignored),
|
||||
* a review reason, or relevant uncertainty remains.
|
||||
* Secure: no blocker and no residual Crit/High or review/info reason under
|
||||
* current evidence (stricter than "nothing actionable").
|
||||
* Unknown: scanner missing or no completed scan.
|
||||
*
|
||||
* Package fix availability (Trivy fixed_version) and container-image update
|
||||
* availability (canonical ImageUpdateService) are distinct facts. A package
|
||||
* fix alone never produces an "Update affected images" instruction.
|
||||
*/
|
||||
|
||||
import {
|
||||
anyTargetIntentConflict,
|
||||
anyTargetIntentUnset,
|
||||
partialTargetsIntentionalWithUnavailable,
|
||||
} from './securityExposureTargets';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
|
||||
/** EPSS score at or above this is treated as an elevated exploitation
|
||||
* likelihood, matching the frontend threshold in SecurityCharts.tsx. */
|
||||
export const HIGH_EPSS_THRESHOLD = 0.1;
|
||||
|
||||
/** Max target rows attached to one posture reason (overview payload).
|
||||
* For exposure reasons rows are per stack/service and may repeat imageRef;
|
||||
* reason.count stays a distinct-image count and can diverge from targets.length. */
|
||||
export const POSTURE_TARGET_CAP = 200;
|
||||
|
||||
export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown';
|
||||
|
||||
/** Valid Security tab targets for a posture reason CTA. Mirrors the frontend
|
||||
@@ -35,7 +56,10 @@ export type SecurityPostureTargetTab =
|
||||
|
||||
export type PostureReasonKind =
|
||||
| 'fixable_cve'
|
||||
| 'waiting_upstream'
|
||||
| 'update_check_uncertain'
|
||||
| 'known_exploited'
|
||||
| 'elevated_exploit_risk'
|
||||
| 'secret'
|
||||
| 'dangerous_compose'
|
||||
| 'public_exposure'
|
||||
@@ -43,8 +67,37 @@ export type PostureReasonKind =
|
||||
| 'failed_scan'
|
||||
| 'needs_review';
|
||||
|
||||
/** Bounded finding identities that drive a vulnerability-derived posture reason. */
|
||||
export interface PostureDriverFinding {
|
||||
vulnerabilityId: string;
|
||||
imageRef: string;
|
||||
}
|
||||
|
||||
export type PostureReasonSeverity = 'blocker' | 'review' | 'info';
|
||||
|
||||
/**
|
||||
* Image identity behind a posture reason (raw scan image_ref).
|
||||
* Exposure reasons may enrich with stack, service, and Networking intent;
|
||||
* other reasons are typically imageRef-only.
|
||||
*/
|
||||
export interface PostureTarget {
|
||||
imageRef: string;
|
||||
/** Stack with configured beyond-loopback or host-network exposure. */
|
||||
stackName?: string;
|
||||
/** Service within that stack. */
|
||||
serviceName?: string;
|
||||
exposureReason?: 'published-port' | 'host-network' | null;
|
||||
/** Effective intent when context is available and set; omit when unset. */
|
||||
exposureIntent?: ExposureIntent;
|
||||
/**
|
||||
* Intent resolution for this service.
|
||||
* unavailable is distinct from unset (DB/context failure vs no classification).
|
||||
*/
|
||||
intentStatus?: 'set' | 'unset' | 'unavailable';
|
||||
/** True when intent is internal/same-node but configured exposure is beyond loopback. */
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
export interface PostureReason {
|
||||
kind: PostureReasonKind;
|
||||
count: number;
|
||||
@@ -55,6 +108,22 @@ export interface PostureReason {
|
||||
description: string;
|
||||
/** Which Security tab the CTA navigates to. */
|
||||
targetTab: SecurityPostureTargetTab;
|
||||
/** Optional Open-button label; when omitted the UI derives from targetTab. */
|
||||
actionLabel?: string;
|
||||
/**
|
||||
* Target rows for this reason (image-only, or per stack/service for exposure).
|
||||
* May repeat imageRef. Omitted when empty or unknown.
|
||||
*/
|
||||
targets?: PostureTarget[];
|
||||
/**
|
||||
* Exact contributing findings for vulnerability-derived reasons (capped).
|
||||
* Older remotes omit this field.
|
||||
*/
|
||||
drivers?: PostureDriverFinding[];
|
||||
/** Full contributing driver count before cap; omit when drivers omitted. */
|
||||
driverCount?: number;
|
||||
/** True when driverCount exceeds the attached drivers array length. */
|
||||
driversTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface PostureAction {
|
||||
@@ -63,6 +132,12 @@ export interface PostureAction {
|
||||
/** The reason kind that produced this action, so the UI can target the
|
||||
* affected items precisely (e.g. filter Images to fixable findings). */
|
||||
kind: PostureReasonKind;
|
||||
/** Same targets as the reason that produced this action, when available. */
|
||||
targets?: PostureTarget[];
|
||||
/** Same drivers as the reason that produced this action, when available. */
|
||||
drivers?: PostureDriverFinding[];
|
||||
driverCount?: number;
|
||||
driversTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityPostureFacts {
|
||||
@@ -70,31 +145,167 @@ export interface SecurityPostureFacts {
|
||||
scannerAvailable: boolean;
|
||||
/** At least one scan has completed (a freshly installed node has none). */
|
||||
hasCompletedScan: boolean;
|
||||
/** Critical/High findings with a fix available, net of suppressions. */
|
||||
/** Critical/High findings with a package fix available, net of suppressions. */
|
||||
fixableCriticalHigh: number;
|
||||
/**
|
||||
* Subset of package-fix Crit/High findings whose managed image has a
|
||||
* confirmed applicable image update (hasUpdate + checkStatus ok).
|
||||
*/
|
||||
fixableWithImageUpdate: number;
|
||||
/**
|
||||
* Package-fix Crit/High on managed images where every checkable match is an
|
||||
* authoritative negative (ok, no update, fresh).
|
||||
*/
|
||||
fixableWaitingUpstream: number;
|
||||
/**
|
||||
* Package-fix Crit/High where update availability could not be established
|
||||
* (partial, failed, stale, disabled, not_checkable, no stack match, etc.).
|
||||
*/
|
||||
fixableUpdateUnknown: number;
|
||||
/** When true, uncertain rows should explain disabled checks (no Check again). */
|
||||
updateChecksDisabled: boolean;
|
||||
/** Detected secrets (not suppressible in the current model). */
|
||||
secrets: number;
|
||||
/** High-severity Compose misconfigurations, net of acknowledgements. */
|
||||
dangerousCompose: number;
|
||||
/** Known-exploited (CISA KEV) findings among non-suppressed Critical/High. */
|
||||
knownExploited: number;
|
||||
/** Total affected services published to a non-loopback address (legacy;
|
||||
* exposedBlocker + exposedReview is the authoritative split). */
|
||||
/** Distinct Crit/High-index images with configured beyond-loopback exposure. */
|
||||
publiclyExposed: number;
|
||||
/** Exposed images with KEV, fixable, or elevated-EPSS findings (blocker). */
|
||||
exposedBlocker: number;
|
||||
/** Exposed images without KEV, fix, or elevated EPSS (review only). */
|
||||
exposedReview: number;
|
||||
/** Raw Critical scanner detections (for the Monitoring fallback). */
|
||||
/**
|
||||
* Exposed images whose Networking intent conflicts with configured exposure
|
||||
* (internal/same-node while beyond loopback), with unsuppressed Crit/High.
|
||||
* This is the only exposure-correctness blocker.
|
||||
*/
|
||||
exposureIntentConflict: number;
|
||||
/**
|
||||
* Exposed images with unset/unavailable/non-intentional intent and
|
||||
* unsuppressed Crit/High. Review-level: does not force Action needed alone.
|
||||
*/
|
||||
exposedUnclassified: number;
|
||||
/**
|
||||
* Network-exposed images with unsuppressed Crit/High at EPSS >= threshold.
|
||||
* Independent Security driver; intentional exposure is context, not the action.
|
||||
*/
|
||||
elevatedExploitRisk: number;
|
||||
/** Raw Critical scanner detections (UI tiles; triage-blind). */
|
||||
rawCritical: number;
|
||||
/** Raw High scanner detections (for the Monitoring fallback). */
|
||||
/** Raw High scanner detections (UI tiles; triage-blind). */
|
||||
rawHigh: number;
|
||||
/**
|
||||
* Crit/High that still block Secure after Secure-clearing triage.
|
||||
* Unsuppressed, accepted, and ignored count; not_affected, false_positive,
|
||||
* and fixed do not.
|
||||
*/
|
||||
residualCriticalHigh: number;
|
||||
/** Images whose latest scan is older than the stale threshold. */
|
||||
staleScans: number;
|
||||
/** Scans that terminated with an error. */
|
||||
failedScans: number;
|
||||
/** Findings with triage_status = 'needs_review' (not dismissed, not accepted). */
|
||||
needsReview: number;
|
||||
/** Raw image_refs for Images-bound reasons (optional; omit when unknown). */
|
||||
fixableWithImageUpdateTargets?: string[];
|
||||
fixableWaitingUpstreamTargets?: string[];
|
||||
fixableUpdateUnknownTargets?: string[];
|
||||
knownExploitedTargets?: string[];
|
||||
knownExploitedDrivers?: PostureDriverFinding[];
|
||||
knownExploitedDriverCount?: number;
|
||||
knownExploitedDriversTruncated?: boolean;
|
||||
/** Per-service exposure targets (may repeat imageRef across stack/service). */
|
||||
exposureIntentConflictTargets?: PostureTarget[];
|
||||
exposedUnclassifiedTargets?: PostureTarget[];
|
||||
elevatedExploitRiskTargets?: PostureTarget[];
|
||||
elevatedExploitRiskDrivers?: PostureDriverFinding[];
|
||||
elevatedExploitRiskDriverCount?: number;
|
||||
elevatedExploitRiskDriversTruncated?: boolean;
|
||||
fixableWithImageUpdateDrivers?: PostureDriverFinding[];
|
||||
fixableWithImageUpdateDriverCount?: number;
|
||||
fixableWithImageUpdateDriversTruncated?: boolean;
|
||||
fixableWaitingUpstreamDrivers?: PostureDriverFinding[];
|
||||
fixableWaitingUpstreamDriverCount?: number;
|
||||
fixableWaitingUpstreamDriversTruncated?: boolean;
|
||||
fixableUpdateUnknownDrivers?: PostureDriverFinding[];
|
||||
fixableUpdateUnknownDriverCount?: number;
|
||||
fixableUpdateUnknownDriversTruncated?: boolean;
|
||||
}
|
||||
|
||||
/** Cap and convert raw refs to PostureTarget[]. Returns truncated=true when capped. */
|
||||
export function capPostureTargets(refs: string[] | undefined): {
|
||||
targets: PostureTarget[] | undefined;
|
||||
truncated: boolean;
|
||||
} {
|
||||
if (!refs || refs.length === 0) return { targets: undefined, truncated: false };
|
||||
return {
|
||||
targets: refs.slice(0, POSTURE_TARGET_CAP).map((imageRef) => ({ imageRef })),
|
||||
truncated: refs.length > POSTURE_TARGET_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cap enriched target rows (imageRef+stack+service), preferring conflict/unset/unavailable first. */
|
||||
export function capPostureTargetRows(rows: PostureTarget[] | undefined): {
|
||||
targets: PostureTarget[] | undefined;
|
||||
truncated: boolean;
|
||||
} {
|
||||
if (!rows || rows.length === 0) return { targets: undefined, truncated: false };
|
||||
const sorted = [...rows].sort((a, b) => postureTargetExposureRank(a) - postureTargetExposureRank(b));
|
||||
return {
|
||||
targets: sorted.slice(0, POSTURE_TARGET_CAP),
|
||||
truncated: rows.length > POSTURE_TARGET_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
function postureTargetExposureRank(t: PostureTarget): number {
|
||||
if (t.intentConflict) return 0;
|
||||
if (t.intentStatus === 'unset') return 1;
|
||||
if (t.intentStatus === 'unavailable') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function attachCappedTargets(
|
||||
reason: PostureReason,
|
||||
capped: { targets: PostureTarget[] | undefined; truncated: boolean },
|
||||
): { reason: PostureReason; truncated: boolean } {
|
||||
if (!capped.targets) return { reason, truncated: false };
|
||||
return { reason: { ...reason, targets: capped.targets }, truncated: capped.truncated };
|
||||
}
|
||||
|
||||
/** Default CTA label when a reason omits actionLabel. */
|
||||
const DEFAULT_ACTION_LABEL: Partial<Record<PostureReasonKind, string>> = {
|
||||
fixable_cve: 'Review update',
|
||||
known_exploited: 'Review exploited findings',
|
||||
elevated_exploit_risk: 'Review driving findings',
|
||||
secret: 'Review detected secrets',
|
||||
dangerous_compose: 'Review Compose risks',
|
||||
public_exposure: 'Review networking',
|
||||
};
|
||||
|
||||
function actionFrom(reason: PostureReason): PostureAction {
|
||||
const action: PostureAction = {
|
||||
label: reason.actionLabel ?? DEFAULT_ACTION_LABEL[reason.kind] ?? 'Open',
|
||||
targetTab: reason.targetTab,
|
||||
kind: reason.kind,
|
||||
};
|
||||
if (reason.targets) action.targets = reason.targets;
|
||||
if (reason.drivers) action.drivers = reason.drivers;
|
||||
if (reason.driverCount !== undefined) action.driverCount = reason.driverCount;
|
||||
if (reason.driversTruncated !== undefined) action.driversTruncated = reason.driversTruncated;
|
||||
return action;
|
||||
}
|
||||
|
||||
function withDrivers(
|
||||
base: PostureReason,
|
||||
drivers: PostureDriverFinding[] | undefined,
|
||||
driverCount?: number,
|
||||
driversTruncated?: boolean,
|
||||
): PostureReason {
|
||||
if (!drivers || drivers.length === 0) return base;
|
||||
return {
|
||||
...base,
|
||||
drivers,
|
||||
driverCount: driverCount ?? drivers.length,
|
||||
driversTruncated: driversTruncated ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,95 +318,152 @@ export interface SecurityPostureFacts {
|
||||
* All reasons (blocker, review, info) are returned regardless of posture
|
||||
* state. The caller decides which subset to surface.
|
||||
*/
|
||||
const VIEW_FINDINGS_LABEL = 'View findings';
|
||||
const REVIEW_NETWORKING_LABEL = 'Review networking';
|
||||
|
||||
function exposureConflictDescription(targets: PostureTarget[] | undefined): string {
|
||||
const parts = [
|
||||
'Configured exposure beyond loopback conflicts with declared Networking intent (internal or same-node).',
|
||||
];
|
||||
if (anyTargetIntentConflict(targets)) {
|
||||
parts.push('Review networking to align Compose publish settings with intent.');
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function exposureUnclassifiedDescription(targets: PostureTarget[] | undefined, truncated = false): string {
|
||||
const parts = [
|
||||
'Images are configured beyond loopback or with host networking and exposure intent is not yet classified.',
|
||||
];
|
||||
if (anyTargetIntentUnset(targets)) {
|
||||
parts.push('Set exposure intent in Networking.');
|
||||
}
|
||||
const partial = partialTargetsIntentionalWithUnavailable(targets, truncated);
|
||||
if (partial.partial) {
|
||||
const n = partial.unavailableCount;
|
||||
parts.push(
|
||||
`Intent could not be verified for ${n} service${n === 1 ? '' : 's'}.`,
|
||||
);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function derivePostureReasons(f: SecurityPostureFacts): {
|
||||
reasons: PostureReason[];
|
||||
primaryAction: PostureAction | null;
|
||||
/** True when any attached target list was capped at POSTURE_TARGET_CAP. */
|
||||
targetsTruncated: boolean;
|
||||
} {
|
||||
const reasons: PostureReason[] = [];
|
||||
let primaryAction: PostureAction | null = null;
|
||||
let targetsTruncated = false;
|
||||
|
||||
const pushCapped = (
|
||||
base: PostureReason,
|
||||
capped: { targets: PostureTarget[] | undefined; truncated: boolean },
|
||||
): void => {
|
||||
const { reason, truncated } = attachCappedTargets(base, capped);
|
||||
if (truncated) targetsTruncated = true;
|
||||
reasons.push(reason);
|
||||
if (!primaryAction && reason.severity === 'blocker') {
|
||||
primaryAction = actionFrom(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const push = (base: PostureReason, refs?: string[]): void => {
|
||||
pushCapped(base, capPostureTargets(refs));
|
||||
};
|
||||
|
||||
// Blockers. Each of these can keep the masthead red.
|
||||
|
||||
if (f.fixableCriticalHigh > 0) {
|
||||
const r: PostureReason = {
|
||||
if (f.fixableWithImageUpdate > 0) {
|
||||
push(withDrivers({
|
||||
kind: 'fixable_cve',
|
||||
count: f.fixableCriticalHigh,
|
||||
count: f.fixableWithImageUpdate,
|
||||
severity: 'blocker',
|
||||
label: 'Fixable findings',
|
||||
description: 'Critical or High findings with an available fix.',
|
||||
label: 'Newer image available',
|
||||
description: 'Critical or High findings have a newer image available to review. This does not prove the candidate removes the findings.',
|
||||
targetTab: 'images',
|
||||
};
|
||||
reasons.push(r);
|
||||
if (!primaryAction) primaryAction = { label: 'Update affected images', targetTab: r.targetTab, kind: r.kind };
|
||||
actionLabel: 'Review update',
|
||||
}, f.fixableWithImageUpdateDrivers, f.fixableWithImageUpdateDriverCount, f.fixableWithImageUpdateDriversTruncated), f.fixableWithImageUpdateTargets);
|
||||
}
|
||||
|
||||
if (f.knownExploited > 0) {
|
||||
const r: PostureReason = {
|
||||
push(withDrivers({
|
||||
kind: 'known_exploited',
|
||||
count: f.knownExploited,
|
||||
severity: 'blocker',
|
||||
label: 'Known-exploited findings',
|
||||
description: 'Findings in the CISA Known Exploited Vulnerabilities catalog.',
|
||||
targetTab: 'images',
|
||||
};
|
||||
reasons.push(r);
|
||||
if (!primaryAction) primaryAction = { label: 'Review exploited findings', targetTab: r.targetTab, kind: r.kind };
|
||||
}, f.knownExploitedDrivers, f.knownExploitedDriverCount, f.knownExploitedDriversTruncated), f.knownExploitedTargets);
|
||||
}
|
||||
|
||||
if (f.elevatedExploitRisk > 0) {
|
||||
const capped = capPostureTargetRows(f.elevatedExploitRiskTargets);
|
||||
pushCapped(withDrivers({
|
||||
kind: 'elevated_exploit_risk',
|
||||
count: f.elevatedExploitRisk,
|
||||
severity: 'blocker',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
description: 'Critical or High findings on network-exposed images have elevated EPSS. Exposure intent is context; review the driving findings.',
|
||||
targetTab: 'images',
|
||||
actionLabel: 'Review driving findings',
|
||||
}, f.elevatedExploitRiskDrivers, f.elevatedExploitRiskDriverCount, f.elevatedExploitRiskDriversTruncated), capped);
|
||||
}
|
||||
|
||||
if (f.secrets > 0) {
|
||||
const r: PostureReason = {
|
||||
push({
|
||||
kind: 'secret',
|
||||
count: f.secrets,
|
||||
severity: 'blocker',
|
||||
label: 'Detected secrets',
|
||||
description: 'Images with exposed credentials or keys. Review on the Secrets tab.',
|
||||
targetTab: 'secrets',
|
||||
};
|
||||
reasons.push(r);
|
||||
if (!primaryAction) primaryAction = { label: 'Review detected secrets', targetTab: r.targetTab, kind: r.kind };
|
||||
});
|
||||
}
|
||||
|
||||
if (f.dangerousCompose > 0) {
|
||||
const r: PostureReason = {
|
||||
push({
|
||||
kind: 'dangerous_compose',
|
||||
count: f.dangerousCompose,
|
||||
severity: 'blocker',
|
||||
label: 'Unacknowledged Compose risks',
|
||||
description: 'High-severity misconfigurations that have not been acknowledged.',
|
||||
targetTab: 'compose',
|
||||
};
|
||||
reasons.push(r);
|
||||
if (!primaryAction) primaryAction = { label: 'Review Compose risks', targetTab: r.targetTab, kind: r.kind };
|
||||
});
|
||||
}
|
||||
|
||||
if (f.exposedBlocker > 0) {
|
||||
const r: PostureReason = {
|
||||
if (f.exposureIntentConflict > 0) {
|
||||
const capped = capPostureTargetRows(f.exposureIntentConflictTargets);
|
||||
pushCapped({
|
||||
kind: 'public_exposure',
|
||||
count: f.exposedBlocker,
|
||||
count: f.exposureIntentConflict,
|
||||
severity: 'blocker',
|
||||
label: 'Publicly exposed affected images',
|
||||
description: 'Images with fixable, known-exploited, or elevated-EPSS findings published on a public interface.',
|
||||
label: 'Exposure conflicts with declared intent',
|
||||
description: exposureConflictDescription(f.exposureIntentConflictTargets),
|
||||
targetTab: 'images',
|
||||
};
|
||||
reasons.push(r);
|
||||
if (!primaryAction) primaryAction = { label: 'Review public exposure', targetTab: r.targetTab, kind: r.kind };
|
||||
actionLabel: REVIEW_NETWORKING_LABEL,
|
||||
}, capped);
|
||||
}
|
||||
|
||||
// Review items. These appear in-page but do not force a red masthead.
|
||||
|
||||
if (f.exposedReview > 0) {
|
||||
reasons.push({
|
||||
if (f.exposedUnclassified > 0) {
|
||||
const capped = capPostureTargetRows(f.exposedUnclassifiedTargets);
|
||||
pushCapped({
|
||||
kind: 'public_exposure',
|
||||
count: f.exposedReview,
|
||||
count: f.exposedUnclassified,
|
||||
severity: 'review',
|
||||
label: 'Exposed images (monitoring)',
|
||||
description: 'Images published on a public interface with no fix, no KEV, and no elevated EPSS.',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
description: exposureUnclassifiedDescription(f.exposedUnclassifiedTargets, capped.truncated),
|
||||
targetTab: 'images',
|
||||
});
|
||||
actionLabel: VIEW_FINDINGS_LABEL,
|
||||
}, capped);
|
||||
}
|
||||
|
||||
if (f.needsReview > 0) {
|
||||
reasons.push({
|
||||
push({
|
||||
kind: 'needs_review',
|
||||
count: f.needsReview,
|
||||
severity: 'review',
|
||||
@@ -205,10 +473,36 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
|
||||
});
|
||||
}
|
||||
|
||||
if (f.fixableWaitingUpstream > 0) {
|
||||
push(withDrivers({
|
||||
kind: 'waiting_upstream',
|
||||
count: f.fixableWaitingUpstream,
|
||||
severity: 'review',
|
||||
label: 'Waiting for upstream image',
|
||||
description: 'Package fixes exist for findings in this image, but Sencho cannot currently identify a newer image to apply under its latest authoritative check.',
|
||||
targetTab: 'images',
|
||||
actionLabel: VIEW_FINDINGS_LABEL,
|
||||
}, f.fixableWaitingUpstreamDrivers, f.fixableWaitingUpstreamDriverCount, f.fixableWaitingUpstreamDriversTruncated), f.fixableWaitingUpstreamTargets);
|
||||
}
|
||||
|
||||
if (f.fixableUpdateUnknown > 0) {
|
||||
push(withDrivers({
|
||||
kind: 'update_check_uncertain',
|
||||
count: f.fixableUpdateUnknown,
|
||||
severity: 'review',
|
||||
label: 'Update availability unknown',
|
||||
description: f.updateChecksDisabled
|
||||
? 'Package fixes exist, but image update checks are disabled on this node, so Sencho cannot tell whether a newer image is available.'
|
||||
: 'Package fixes exist, but Sencho could not establish whether an applicable image update is available (partial, failed, stale, not checkable, or unmatched image).',
|
||||
targetTab: 'images',
|
||||
actionLabel: VIEW_FINDINGS_LABEL,
|
||||
}, f.fixableUpdateUnknownDrivers, f.fixableUpdateUnknownDriverCount, f.fixableUpdateUnknownDriversTruncated), f.fixableUpdateUnknownTargets);
|
||||
}
|
||||
|
||||
// Info items. Context only, never red.
|
||||
|
||||
if (f.staleScans > 0) {
|
||||
reasons.push({
|
||||
push({
|
||||
kind: 'stale_scan',
|
||||
count: f.staleScans,
|
||||
severity: 'info',
|
||||
@@ -219,7 +513,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
|
||||
}
|
||||
|
||||
if (f.failedScans > 0) {
|
||||
reasons.push({
|
||||
push({
|
||||
kind: 'failed_scan',
|
||||
count: f.failedScans,
|
||||
severity: 'info',
|
||||
@@ -229,7 +523,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
|
||||
});
|
||||
}
|
||||
|
||||
return { reasons, primaryAction };
|
||||
return { reasons, primaryAction, targetsTruncated };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +537,6 @@ export function deriveSecurityPosture(f: SecurityPostureFacts): SecurityPostureS
|
||||
if (!f.scannerAvailable || !f.hasCompletedScan) return 'Unknown';
|
||||
const { reasons } = derivePostureReasons(f);
|
||||
if (reasons.some((r) => r.severity === 'blocker')) return 'Action needed';
|
||||
if (f.rawCritical > 0 || f.rawHigh > 0 || reasons.length > 0) return 'Monitoring';
|
||||
if (f.residualCriticalHigh > 0 || reasons.length > 0) return 'Monitoring';
|
||||
return 'Secure';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Sencho rollback-hold image identity.
|
||||
*
|
||||
* Full-stack recovery tags images as `sencho-rb/<generation>/<service>:hold`.
|
||||
* Those refs are Sencho-internal recovery state, not operator inventory or
|
||||
* Security scan targets. Shared so Resources, Trivy, and Security agree.
|
||||
*/
|
||||
|
||||
export const SENCHO_ROLLBACK_HOLD_PREFIX = 'sencho-rb/';
|
||||
|
||||
/** SQLite LIKE pattern matching any Sencho rollback-hold image_ref. */
|
||||
export const SENCHO_ROLLBACK_HOLD_SQL_LIKE = `${SENCHO_ROLLBACK_HOLD_PREFIX}%`;
|
||||
|
||||
/** True when an image reference is a Sencho synthetic rollback-hold tag. */
|
||||
export function isSenchoRollbackHoldRef(imageRef: string): boolean {
|
||||
return imageRef.startsWith(SENCHO_ROLLBACK_HOLD_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every visible RepoTag is a synthetic hold tag (hold-only image).
|
||||
* Dual-tagged images (registry tag + hold) return false so they stay visible
|
||||
* under the real tag.
|
||||
*/
|
||||
export function isFullySyntheticHoldImage(repoTags: string[]): boolean {
|
||||
return repoTags.length > 0 && repoTags.every((tag) => isSenchoRollbackHoldRef(tag));
|
||||
}
|
||||
@@ -26,6 +26,36 @@ export const DISMISSING_STATUSES: ReadonlySet<TriageStatus> = new Set([
|
||||
'not_affected', 'accepted', 'fixed', 'false_positive', 'ignored',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dismissing statuses that clear Crit/High from residual Secure-gate risk.
|
||||
* Accepted and ignored stay residual (operator is still carrying the risk).
|
||||
* Fixed clears under the current Secure contract; re-verifying Fixed over time
|
||||
* is a separate evidence-freshness concern.
|
||||
*/
|
||||
export const SECURE_CLEARING_STATUS_LIST = [
|
||||
'not_affected', 'false_positive', 'fixed',
|
||||
] as const;
|
||||
export type SecureClearingStatus = typeof SECURE_CLEARING_STATUS_LIST[number];
|
||||
export const SECURE_CLEARING_STATUSES: ReadonlySet<SecureClearingStatus> = new Set(
|
||||
SECURE_CLEARING_STATUS_LIST,
|
||||
);
|
||||
|
||||
type _SecureClearingIsDismissing = SecureClearingStatus extends (
|
||||
'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored'
|
||||
) ? true : never;
|
||||
const _secureClearingIsDismissing: _SecureClearingIsDismissing = true;
|
||||
void _secureClearingIsDismissing;
|
||||
|
||||
/** True when a Crit/High finding still blocks Secure (residual material risk). */
|
||||
export function countsTowardResidualCriticalHigh(
|
||||
decision: { suppressed: boolean; triage_status?: TriageStatus },
|
||||
): boolean {
|
||||
if (!decision.suppressed) return true;
|
||||
const status = decision.triage_status;
|
||||
if (status == null) return true;
|
||||
return !(SECURE_CLEARING_STATUS_LIST as readonly string[]).includes(status);
|
||||
}
|
||||
|
||||
/** Optional OpenVEX-aligned justification taxonomy (never required). */
|
||||
export const TRIAGE_JUSTIFICATIONS = [
|
||||
'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present',
|
||||
|
||||
Reference in New Issue
Block a user