mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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',
|
||||
|
||||
@@ -32,7 +32,7 @@ The dialog has the following fields:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **CVE or advisory ID** | Required. Accepts both `CVE-YYYY-NNNN` and `GHSA-xxxx-xxxx-xxxx`. |
|
||||
| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, affected, fixed, ignored, or needs review. A decided state (accepted, not affected, false positive, fixed, ignored) stops the finding from driving the action posture; "needs review" and "affected" keep it counted but still actionable. |
|
||||
| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, affected, fixed, ignored, or needs review. Not affected, false positive, and fixed clear residual Crit/High so Secure can become reachable. Accepted and ignored residual risk stay on Monitoring and do not turn Secure green. Needs review and affected stay actionable. |
|
||||
| **OpenVEX justification** | Required when the triage decision is not affected or false positive. Explains why the vulnerable code is not exploitable, for example vulnerable code not present, vulnerable code not in the execute path, component not present, or inline mitigations already exist. Carried into the OpenVEX export for that decision. |
|
||||
| **Package (optional)** | Leave blank to suppress every occurrence of this CVE across every package, or pin a specific package name (e.g. `openssl`) to narrow the scope. |
|
||||
| **Image pattern (optional)** | Glob applied to image references (`*` matches any sequence, case-sensitive). For example, `lscr.io/linuxserver/*` matches every LinuxServer image, and `*alpine*` matches anything containing `alpine`. Leave blank to apply fleet-wide. |
|
||||
@@ -45,7 +45,14 @@ The dialog has the following fields:
|
||||
|
||||
### Suppressing directly from a scan result
|
||||
|
||||
The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to set the Triage decision (accepted risk by default), add a Reason, an optional Image pattern, and an optional Expiry. Choosing not affected or false positive also requires an OpenVEX justification. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from the **Security** page → **Suppressions** tab instead.
|
||||
The panel's empty state hints at the faster path: from any vulnerability scan, click **Triage finding**
|
||||
on a finding's row. The dialog opens pre-filled with the CVE ID, the package name, and the **Image
|
||||
pattern** set to the current image reference so the decision stays scoped to that image by default.
|
||||
You set the Triage decision (accepted risk by default), add a Reason, and an optional Expiry. You can
|
||||
clear or broaden the image pattern if you intend a wider match. Choosing not affected or false
|
||||
positive also requires an OpenVEX justification. This is the recommended workflow for everyday
|
||||
triage. To start from a blank image pattern for fleet-wide matching, create the rule from the
|
||||
**Security** page → **Suppressions** tab instead.
|
||||
|
||||
### How specificity is resolved
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ When a deploy or update fails, Sencho classifies the failure from the compose ou
|
||||
The sidebar's per-stack **Update** action runs the same path as the editor toolbar, so it shows the same readiness dialog and deploy progress. One click on **Update now** proceeds. On nodes that do not advertise the capability, updates run directly without the dialog.
|
||||
</Accordion>
|
||||
<Accordion title="Why do I see sencho-rb/... images in docker images on the host">
|
||||
Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** on purpose (they are recovery state, not image inventory) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge.
|
||||
Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** and **Security** on purpose (they are recovery state, not image inventory or scan targets) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge, and Security continues to scan that registry tag.
|
||||
</Accordion>
|
||||
<Accordion title="Deleting a rollback-protected image fails">
|
||||
That failure is intentional: the image is protected by an active or recently superseded rollback generation. Open **Resources → Rollback**, find the matching generation, and use **Release** there if you are sure you do not need it. Releasing the current generation means Sencho cannot automatically roll that stack back until its next successful full-stack update.
|
||||
|
||||
+62
-21
@@ -9,7 +9,7 @@ center, so you can answer "what should I look at first?" without hunting through
|
||||
scoped to the active node: the findings and scanner status you see reflect whichever node is selected.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/security/overview.png" alt="Security page Overview tab with an Action needed masthead reading '3 actions: fixable findings, detected secrets' and CRITICAL/HIGH/LAST SCAN stat tiles, a Why Action needed review-queue card, a 30-day risk trend chart, an Action posture breakdown, and a Scan this node button." />
|
||||
<img src="/images/security/overview.png" alt="Security page Overview tab with an Action needed masthead, CRITICAL/HIGH/LAST SCAN stat tiles, a Why Action needed review-queue card, a 30-day risk trend chart, an Action posture breakdown, and a Scan this node button." />
|
||||
</Frame>
|
||||
|
||||
The page is organized into tabs: Overview, Images, Compose risks, Secrets, Policies, Suppressions,
|
||||
@@ -20,17 +20,35 @@ History, and Scanner setup.
|
||||
The overview opens with a status masthead that reads your **action posture** at a glance, the answer
|
||||
to "what can and should I do right now?":
|
||||
|
||||
- **Action needed**: something concrete to act on, such as a fixable Critical or High finding, a
|
||||
detected secret, a dangerous Compose setting, or a known-exploited (CISA KEV) CVE.
|
||||
- **Monitoring**: Critical or High findings exist, but none are currently actionable (no fix
|
||||
available, or already triaged).
|
||||
- **Secure**: nothing actionable right now. This is never a claim that no vulnerabilities exist.
|
||||
- **Action needed**: something concrete to act on that Sencho can support right now, such as a
|
||||
newer image available to review for Critical or High findings, a detected secret, a dangerous
|
||||
Compose setting, a known-exploited (CISA KEV) CVE, elevated exploit risk on a network-exposed
|
||||
workload, or exposure that conflicts with declared Networking intent.
|
||||
- **Monitoring**: no Action needed blocker, but material residual risk, a pending review, or
|
||||
relevant uncertainty remains. Residual Critical/High include undecided findings and accepted or
|
||||
ignored residual risk. Package fixes may exist without an applicable container-image update,
|
||||
workloads may be intentionally network-exposed without an independent Security driver, and
|
||||
waiting-upstream or update-check-uncertain rows appear under **Why Monitoring** without a red
|
||||
masthead.
|
||||
- **Secure**: no Action needed blocker and no residual material Critical/High or pending
|
||||
security-review condition under current evidence. Not affected, false positive, and fixed can
|
||||
clear residual risk for those findings; accepting residual risk does not. This is never a claim
|
||||
that no scanner detections exist.
|
||||
- **Unknown**: the scanner is not installed, or no scan has completed yet.
|
||||
|
||||
Monitoring keeps residual risk visible (raw detections, accepted risk, waiting-upstream rows,
|
||||
intentional exposure context). Secure is stricter: residual Critical/High and review reasons must
|
||||
be cleared, not merely the absence of an immediate action.
|
||||
|
||||
Raw Critical and High counts stay visible next to the posture as **scanner detections**, not as the
|
||||
posture itself: a vulnerable component being present is not the same as a reachable, exploitable risk.
|
||||
The masthead carries a standing note to that effect, and posture weighs fix availability, exploit
|
||||
intelligence, and triage decisions rather than raw severity alone.
|
||||
The masthead carries a standing note to that effect, and posture weighs package-fix evidence,
|
||||
image-update availability, exploit intelligence, exposure, and triage decisions rather than raw
|
||||
severity alone. A Trivy package fix does not by itself mean Sencho can update the image; when a
|
||||
newer image is confirmed available the review queue offers **Review update**, and when no applicable
|
||||
update is identified it surfaces **Waiting for upstream image** instead of an impossible update
|
||||
instruction. Deploy policies remain orthogonal: they may still block admission on package-fix
|
||||
Critical/High even when Security posture is Monitoring.
|
||||
|
||||
An admin on a node with a ready scanner sees a **Scan this node** button above the review queue. It
|
||||
opens a popover to pick any combination of image vulnerabilities, image secrets, and Compose
|
||||
@@ -40,11 +58,18 @@ clean.
|
||||
|
||||
Below the masthead, a **Review queue** card leads the overview when actions or review items exist.
|
||||
When the posture is Action needed the card is titled **Why Action needed** and lists each concrete
|
||||
action with a count and a tab-shortcut button: fixable findings, known-exploited CVEs, detected
|
||||
secrets, unacknowledged Compose risks, and publicly exposed affected images. When only monitoring
|
||||
items remain (exposed images with no fix or known exploit, findings awaiting triage, stale or failed
|
||||
scans) the card is titled **Review queue** and lists them without the red masthead, so the operator
|
||||
can see what to keep an eye on without a permanent alarm.
|
||||
action with a count and a tab-shortcut button: newer images available to review, known-exploited
|
||||
CVEs, elevated exploit risk on network-exposed workloads, detected secrets, unacknowledged Compose
|
||||
risks, and exposure that conflicts with declared Networking intent. Intentional network exposure is
|
||||
Security context, not an independent Action needed reason by itself. When only monitoring items
|
||||
remain (waiting for an upstream image, update availability unknown, network-exposed images not yet
|
||||
classified, findings awaiting triage, stale or failed scans) the card is titled **Why Monitoring**
|
||||
and lists them with View findings shortcuts (and Check again when image update availability could
|
||||
not be established and you can manage the node), so the operator can see what to keep an eye on
|
||||
without a permanent alarm. Shortcuts that carry affected image identities open Images already
|
||||
narrowed to those images, with a clearable banner so you can return to the full list. When the
|
||||
reason includes exact driving findings, opening an image filters the scan report to that contributing
|
||||
set.
|
||||
|
||||
The charts then lead with prioritization rather than raw severity: a **risk trend** for context,
|
||||
an **action posture** breakdown (fixable, known-exploited, needs-review, accepted, not-affected), a
|
||||
@@ -64,11 +89,26 @@ clear "overview unavailable" state and the other tabs keep working.
|
||||
<img src="/images/security/images-tab.png" alt="Security page Images tab listing scanned images with Image, Findings, Last scan, Severity, and Actions columns; findings show critical/high counts and fixable totals, and one row shows a Clean badge." />
|
||||
</Frame>
|
||||
|
||||
Image findings list every scanned image on the active node with its highest severity. Selecting an
|
||||
image opens the full scan report, where you can review vulnerabilities, triage a CVE, compare against
|
||||
another scan, and export a CSV, a SARIF file, or an SBOM. Each finding carries evidence tags so you can
|
||||
tell scary from exploitable at a glance: known-exploited (KEV), EPSS exploitation probability, the
|
||||
CVSS score, and vendor "will not fix" status, alongside whether a fix is available.
|
||||
Image findings list every scanned image on the active node with its highest severity. Sencho
|
||||
rollback-hold tags (`sencho-rb/...:hold`) are recovery state, not Security inventory: node-wide
|
||||
scans skip them, and existing hold-only scan rows stay out of this list and the Overview posture.
|
||||
When you arrive
|
||||
from a Security posture action that named specific images, the list opens already filtered to those
|
||||
images and shows a clearable banner with the reason and count. Images configured beyond loopback (or
|
||||
with host networking) carry a **Network exposed** evidence label on the row during ordinary browsing,
|
||||
not only after a posture drill-down. That label means Compose declares non-loopback reachability; it
|
||||
is not a claim that the service is reachable from the Internet. When Networking exposure intent is
|
||||
available, the row also shows compact intent evidence (for example Intent: public, Intent mismatch,
|
||||
or Intent: not classified). Open Networking from that evidence to review or correct classification
|
||||
for the matching stack. Intentional exposure raises the security relevance of vulnerable findings
|
||||
but does not by itself keep the masthead on Action needed. Open the image, remediate, or triage
|
||||
individual findings (Accepted risk and related decisions). There is no image-level "accept residual
|
||||
risk" control. Compose Doctor acknowledgements are a separate triage surface and do not clear
|
||||
Security exposure context. Selecting
|
||||
an image opens the full scan report, where you can review vulnerabilities, triage a finding, compare
|
||||
against another scan, and export a CSV, a SARIF file, or an SBOM. Each finding carries evidence tags
|
||||
so you can tell scary from exploitable at a glance: known-exploited (KEV), EPSS exploitation
|
||||
probability, the CVSS score, and vendor "will not fix" status, alongside whether a fix is available.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/security/scan-report.png" alt="Vulnerability scan report sheet for nginx:latest showing 340 vulnerabilities, a Critical/High/Medium/Low summary, Compare/CSV/SARIF/SBOM export buttons, and a findings table with CVE, package, severity, installed version, and per-row EPSS and CVSS evidence tags." />
|
||||
@@ -99,7 +139,7 @@ the secret findings for a scan.
|
||||
## Policies
|
||||
|
||||
<Frame>
|
||||
<img src="/images/security/add-policy.png" alt="New policy dialog with a Name field, an optional glob-style Stack pattern field, Block conditions toggles for Severity threshold, Known-exploited (KEV), and Fixable Critical/High, a Block on deploy toggle, and an Enabled toggle." />
|
||||
<img src="/images/security/add-policy.png" alt="New policy dialog with a Name field, an optional glob-style Stack pattern field, Block conditions toggles for Severity threshold, Known-exploited (KEV), and Package fix available (Critical/High), a Block on deploy toggle, and an Enabled toggle." />
|
||||
</Frame>
|
||||
|
||||
The Policies tab manages deploy-enforcement scan policies: a policy names one or more block
|
||||
@@ -121,8 +161,9 @@ in Settings. These are governed by the local instance, so this tab is shown when
|
||||
node; switch to the local node to manage them.
|
||||
|
||||
Suppressing a CVE records a **triage decision**: accepted risk, not affected, false positive, fixed,
|
||||
ignored, or needs review, with an optional OpenVEX justification. Decided findings stop driving the
|
||||
action posture; a "needs review" decision stays counted but keeps the finding actionable. You can
|
||||
ignored, or needs review, with an optional OpenVEX justification. Not affected, false positive, and
|
||||
fixed clear residual Crit/High for Secure. Accepted and ignored residual risk stay on Monitoring and
|
||||
do not turn the page green. Needs review and affected stay actionable. You can
|
||||
export the fleet's triage decisions as an OpenVEX document for use with other tooling.
|
||||
|
||||
## History
|
||||
|
||||
+4
-3
@@ -1890,9 +1890,10 @@ paths:
|
||||
description: |
|
||||
Computes the same preview as GET. When every image reports
|
||||
`check_status: ok` and `has_update` is false (mixed `ok` +
|
||||
`not_checkable` does not clear), clears sticky confirmed update rows
|
||||
for the stack and sets `reconciled: true`. Requires `stack:read`
|
||||
permission.
|
||||
`not_checkable` does not clear), deletes sticky partial, failed,
|
||||
and ok+true rows and sets `reconciled: true` only when a row was
|
||||
deleted. Existing ok+false rows are kept (`reconciled` stays false).
|
||||
Requires `stack:read` permission.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stackName"
|
||||
- $ref: "#/components/parameters/nodeId"
|
||||
|
||||
@@ -18,12 +18,17 @@ import { Masthead, type Tone } from './mobile/mobile-ui';
|
||||
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole, PostureReasonKind } from '@/types/security';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { SuppressionsPanel } from './settings/SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
|
||||
import { OverviewTab } from './security/OverviewTab';
|
||||
import { reasonImageFilter } from './security/postureNavigation';
|
||||
import {
|
||||
targetingFromTargets,
|
||||
type ImagesTargetingInput,
|
||||
type ImagesTargetingState,
|
||||
} from './security/imagesTargeting';
|
||||
import { ImagesTab } from './security/ImagesTab';
|
||||
import { FindingsTab } from './security/FindingsTab';
|
||||
import { ScanPolicyManager } from './security/ScanPolicyManager';
|
||||
@@ -86,19 +91,67 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
|
||||
// Filter to preselect on the Images tab when arriving from an overview link
|
||||
// (e.g. "fixable findings"). Null leaves the Images tab on its own default.
|
||||
const [inspectDriverVulnerabilityIds, setInspectDriverVulnerabilityIds] = useState<string[] | undefined>(undefined);
|
||||
// Filter / targeting for the Images tab when arriving from an overview link.
|
||||
// SecurityView owns both; ImagesTab never clears targeting locally (R1).
|
||||
const [imagesFilter, setImagesFilter] = useState<ImageFilterValue | null>(null);
|
||||
const [imagesFilterToken, setImagesFilterToken] = useState(0);
|
||||
const [imagesTargeting, setImagesTargeting] = useState<ImagesTargetingState | null>(null);
|
||||
|
||||
// Navigate between security tabs, optionally preselecting an Images filter so
|
||||
// an overview action link lands on exactly the affected images.
|
||||
const handleNavigate = useCallback((tab: SecurityTab, filter?: ImageFilterValue) => {
|
||||
if (tab === 'images' && filter) setImagesFilter(filter);
|
||||
// Navigate between security tabs, optionally preselecting an Images filter
|
||||
// and/or posture targeting so an overview action lands on the affected images.
|
||||
const handleNavigate = useCallback((
|
||||
tab: SecurityTab,
|
||||
filter?: ImageFilterValue,
|
||||
targeting?: ImagesTargetingInput,
|
||||
) => {
|
||||
if (tab === 'images') {
|
||||
if (targeting && targeting.imageRefs.length > 0) {
|
||||
setImagesTargeting((prev) => ({
|
||||
kind: targeting.kind,
|
||||
label: targeting.label,
|
||||
imageRefs: targeting.imageRefs,
|
||||
targets: targeting.targets,
|
||||
...(targeting.drivers ? { drivers: targeting.drivers } : {}),
|
||||
...(targeting.driverCount !== undefined ? { driverCount: targeting.driverCount } : {}),
|
||||
...(targeting.driversTruncated !== undefined
|
||||
? { driversTruncated: targeting.driversTruncated }
|
||||
: {}),
|
||||
token: (prev?.token ?? 0) + 1,
|
||||
}));
|
||||
// R2: targeting navigation resets severity unless an explicit filter is supplied.
|
||||
setImagesFilter(filter ?? null);
|
||||
setImagesFilterToken((t) => t + 1);
|
||||
} else {
|
||||
setImagesTargeting(null);
|
||||
if (filter) {
|
||||
setImagesFilter(filter);
|
||||
setImagesFilterToken((t) => t + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
onTabChange(tab);
|
||||
}, [onTabChange]);
|
||||
|
||||
const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => {
|
||||
const clearImagesTargeting = useCallback(() => {
|
||||
setImagesTargeting(null);
|
||||
}, []);
|
||||
|
||||
// Drop Images drill-down state when the active node changes so refs from
|
||||
// node A never filter node B's summaries.
|
||||
useEffect(() => {
|
||||
setImagesTargeting(null);
|
||||
setImagesFilter(null);
|
||||
setImagesFilterToken(0);
|
||||
}, [activeNode?.id]);
|
||||
|
||||
const onInspect = useCallback((
|
||||
scanId: number,
|
||||
initialTab?: ScanDetailTab,
|
||||
driverVulnerabilityIds?: string[],
|
||||
) => {
|
||||
setInspectInitialTab(initialTab);
|
||||
setInspectDriverVulnerabilityIds(driverVulnerabilityIds);
|
||||
setInspectScanId(scanId);
|
||||
}, []);
|
||||
|
||||
@@ -229,12 +282,23 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
// The scanner-detections disclaimer rides as an info affordance next to the
|
||||
// scanned-images count rather than a standing caption below the masthead.
|
||||
// When posture is Action needed, the subtitle leads with the action count and
|
||||
// top blocker labels so the operator sees "why red" without opening the page.
|
||||
// When posture is Action needed or Monitoring, the subtitle leads with the
|
||||
// residual Crit/High count and top reason labels so the operator sees why
|
||||
// without opening the review queue.
|
||||
const blockers = overview?.postureReasons?.filter((r) => r.severity === 'blocker') ?? [];
|
||||
const actionSummary = overview?.posture === 'Action needed' && blockers.length > 0
|
||||
? `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · `
|
||||
: null;
|
||||
const reviewReasons = overview?.postureReasons?.filter((r) => r.severity === 'review') ?? [];
|
||||
const residualCritHigh = (overview?.rawCritical ?? overview?.critical ?? 0)
|
||||
+ (overview?.rawHigh ?? overview?.high ?? 0);
|
||||
let actionSummary: string | null = null;
|
||||
if (overview?.posture === 'Action needed' && blockers.length > 0) {
|
||||
actionSummary = `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · `;
|
||||
} else if (overview?.posture === 'Monitoring' && residualCritHigh > 0) {
|
||||
const labels = (reviewReasons.length > 0 ? reviewReasons : overview.postureReasons ?? [])
|
||||
.slice(0, 2)
|
||||
.map((r) => r.label.toLowerCase());
|
||||
const labelPart = labels.length > 0 ? `: ${labels.join(', ')}` : '';
|
||||
actionSummary = `${residualCritHigh} residual Crit/High${labelPart} · `;
|
||||
}
|
||||
const subtitle = overview ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{actionSummary ? <span>{actionSummary}</span> : null}
|
||||
@@ -268,6 +332,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
onNavigate={handleNavigate}
|
||||
onInspect={onInspect}
|
||||
canScan={canScanNode}
|
||||
canManageNode={!!activeNode?.id && can('node:manage', 'node', String(activeNode.id))}
|
||||
onScanComplete={() => setReloadToken((t) => t + 1)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -283,6 +348,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
scanningRef={scanningRef}
|
||||
onScan={scanImage}
|
||||
initialFilter={imagesFilter ?? undefined}
|
||||
filterToken={imagesFilterToken}
|
||||
targeting={imagesTargeting}
|
||||
onClearTargeting={clearImagesTargeting}
|
||||
posturePartial={overview?.posturePartial === true}
|
||||
nodeId={activeNode?.id}
|
||||
/>
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
@@ -338,7 +408,18 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
driverVulnerabilityIds={inspectDriverVulnerabilityIds}
|
||||
driverFilterMode={
|
||||
imagesTargeting?.kind === 'waiting_upstream' || imagesTargeting?.kind === 'update_check_uncertain'
|
||||
? 'monitoring'
|
||||
: 'action'
|
||||
}
|
||||
driverCount={imagesTargeting?.driverCount}
|
||||
driversTruncated={imagesTargeting?.driversTruncated}
|
||||
onClose={() => {
|
||||
setInspectScanId(null);
|
||||
setInspectDriverVulnerabilityIds(undefined);
|
||||
}}
|
||||
canGenerateSbom={canReadSecurityExports}
|
||||
canExportSarif={canReadSecurityExports}
|
||||
canCompare
|
||||
@@ -391,10 +472,21 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
{overview?.posture === 'Action needed' && overview.primaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(
|
||||
overview.primaryAction!.targetTab,
|
||||
reasonImageFilter(overview.primaryAction!.kind),
|
||||
)}
|
||||
onClick={() => {
|
||||
const action = overview.primaryAction!;
|
||||
const blockerLabel = overview.postureReasons?.find(
|
||||
(r) => r.kind === action.kind && r.severity === 'blocker',
|
||||
)?.label ?? action.label;
|
||||
const targeting = targetingFromTargets(
|
||||
action.kind as PostureReasonKind,
|
||||
blockerLabel,
|
||||
action.targets,
|
||||
action.drivers,
|
||||
{ driverCount: action.driverCount, driversTruncated: action.driversTruncated },
|
||||
);
|
||||
const filter = targeting ? undefined : reasonImageFilter(action.kind);
|
||||
handleNavigate(action.targetTab, filter, targeting);
|
||||
}}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
|
||||
>
|
||||
{overview.primaryAction.label} →
|
||||
|
||||
@@ -58,7 +58,7 @@ import type {
|
||||
ScanDetailTab,
|
||||
TriageStatus,
|
||||
} from '@/types/security';
|
||||
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus } from '@/lib/triage';
|
||||
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus, triageStatusLabel } from '@/lib/triage';
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
scanId: number | null;
|
||||
@@ -75,6 +75,19 @@ interface VulnerabilityScanSheetProps {
|
||||
* matching tab so it lands there even when the scan also has CVEs.
|
||||
*/
|
||||
initialTab?: FindingTab;
|
||||
/**
|
||||
* When set (from a posture reason with drivers), the vuln list is filtered
|
||||
* to these CVE/GHSA ids and a driving / monitoring findings banner shows.
|
||||
*/
|
||||
driverVulnerabilityIds?: string[];
|
||||
/**
|
||||
* action = Action-needed driver set; monitoring = waiting/uncertain review set.
|
||||
* Defaults to action when omitted.
|
||||
*/
|
||||
driverFilterMode?: 'action' | 'monitoring';
|
||||
/** Full driver count before cap (for truncated title). */
|
||||
driverCount?: number;
|
||||
driversTruncated?: boolean;
|
||||
}
|
||||
|
||||
interface SuppressDialogState {
|
||||
@@ -159,10 +172,21 @@ function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
|
||||
if (typeof d.cvss_score === 'number') {
|
||||
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
|
||||
}
|
||||
if (d.triage_status) {
|
||||
tags.push(
|
||||
<EvidenceTag key="triage" tone={d.triage_status === 'affected' || d.triage_status === 'needs_review' ? 'warn' : 'muted'}>
|
||||
{triageStatusLabel(d.triage_status)}
|
||||
</EvidenceTag>,
|
||||
);
|
||||
}
|
||||
if (tags.length === 0) return null;
|
||||
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
|
||||
}
|
||||
|
||||
function isActiveTriageStatus(status: TriageStatus | undefined): boolean {
|
||||
return status === 'affected' || status === 'needs_review';
|
||||
}
|
||||
|
||||
export function VulnerabilityScanSheet({
|
||||
scanId,
|
||||
onClose,
|
||||
@@ -172,6 +196,10 @@ export function VulnerabilityScanSheet({
|
||||
canCompare = false,
|
||||
canManageSuppressions: canManageSuppressionsProp = false,
|
||||
initialTab,
|
||||
driverVulnerabilityIds,
|
||||
driverFilterMode = 'action',
|
||||
driverCount,
|
||||
driversTruncated = false,
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [isReplica, setIsReplica] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -283,9 +311,16 @@ export function VulnerabilityScanSheet({
|
||||
}, [scanId, load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (severityFilter === 'ALL') return details;
|
||||
return details.filter((d) => d.severity === severityFilter);
|
||||
}, [details, severityFilter]);
|
||||
let rows = details;
|
||||
if (driverVulnerabilityIds && driverVulnerabilityIds.length > 0) {
|
||||
const allow = new Set(driverVulnerabilityIds);
|
||||
rows = rows.filter((d) => allow.has(d.vulnerability_id));
|
||||
}
|
||||
if (severityFilter === 'ALL') return rows;
|
||||
return rows.filter((d) => d.severity === severityFilter);
|
||||
}, [details, severityFilter, driverVulnerabilityIds]);
|
||||
|
||||
const drivingFindings = (driverVulnerabilityIds?.length ?? 0) > 0;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
@@ -366,13 +401,13 @@ export function VulnerabilityScanSheet({
|
||||
setSuppressForm({
|
||||
cveId: d.vulnerability_id,
|
||||
pkgName: d.pkg_name,
|
||||
imagePattern: '',
|
||||
imagePattern: scan?.image_ref ?? '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
status: 'accepted',
|
||||
justification: '',
|
||||
});
|
||||
}, []);
|
||||
}, [scan?.image_ref]);
|
||||
|
||||
const submitSuppression = useCallback(async () => {
|
||||
if (!suppressForm) return;
|
||||
@@ -550,7 +585,7 @@ export function VulnerabilityScanSheet({
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
{scan.total_vulnerabilities} vulns · {scan.fixable_count} fixable · {scan.triggered_by}
|
||||
{scan.publicly_exposed === true && (
|
||||
<EvidenceTag tone="warn">Published service</EvidenceTag>
|
||||
<EvidenceTag tone="warn">Network exposed</EvidenceTag>
|
||||
)}
|
||||
</span>
|
||||
) : (loading ? 'Loading…' : 'No scan');
|
||||
@@ -742,7 +777,33 @@ export function VulnerabilityScanSheet({
|
||||
</SheetSection>
|
||||
|
||||
{tab === 'vulns' && (
|
||||
<SheetSection title={`Vulnerabilities · ${totalDetails}`} className="flex min-h-0 flex-1 flex-col">
|
||||
<SheetSection
|
||||
title={
|
||||
drivingFindings
|
||||
? (() => {
|
||||
const n = filtered.length;
|
||||
const total = driverCount ?? n;
|
||||
const truncated = driversTruncated && total > n;
|
||||
if (driverFilterMode === 'monitoring') {
|
||||
return truncated
|
||||
? `Findings under Monitoring · showing ${n} of ${total}`
|
||||
: `Findings under Monitoring · ${n} finding${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
return truncated
|
||||
? `Driving current Security action · showing ${n} of ${total}`
|
||||
: `Driving current Security action · ${n} finding${n === 1 ? '' : 's'}`;
|
||||
})()
|
||||
: `Vulnerabilities · ${totalDetails}`
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{drivingFindings ? (
|
||||
<p className="text-xs text-stat-subtitle mb-3">
|
||||
{driverFilterMode === 'monitoring'
|
||||
? 'Showing findings under Monitoring for this image. Remediating or triaging these findings can change Security posture.'
|
||||
: 'Showing the findings that currently drive Action needed for this image. Remediating or triaging these findings updates Security posture.'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 flex-wrap mb-3">
|
||||
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
||||
<Button
|
||||
@@ -807,10 +868,11 @@ export function VulnerabilityScanSheet({
|
||||
<TableBody>
|
||||
{pageItems.map((d) => {
|
||||
const href = cveUrl(d.vulnerability_id, d.primary_url);
|
||||
const dimmed = Boolean(d.suppressed) && !isActiveTriageStatus(d.triage_status);
|
||||
return (
|
||||
<TableRow
|
||||
key={d.id}
|
||||
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
|
||||
className={cn(SEVERITY_ROW_TINT[d.severity], dimmed && 'opacity-60')}
|
||||
>
|
||||
<TableCell className="font-mono text-xs tabular-nums align-top">
|
||||
<span className="flex flex-col">
|
||||
@@ -866,7 +928,8 @@ export function VulnerabilityScanSheet({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="Suppress this CVE"
|
||||
title="Triage finding"
|
||||
aria-label="Triage finding"
|
||||
onClick={() => openSuppressDialog(d)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
@@ -1115,9 +1178,9 @@ export function VulnerabilityScanSheet({
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Suppress CVE</DialogTitle>
|
||||
<DialogTitle>Triage finding</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Accept this CVE as known-benign so it stops triggering alerts across the fleet.
|
||||
Record a triage decision for this finding so it stops or continues driving security posture.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{suppressForm && (
|
||||
@@ -1145,7 +1208,7 @@ export function VulnerabilityScanSheet({
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
|
||||
Defaults to this image. Clear or broaden the pattern to apply more widely; leave blank for every image.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1224,7 +1287,7 @@ export function VulnerabilityScanSheet({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitSuppression} disabled={savingSuppression}>
|
||||
{savingSuppression ? 'Saving...' : 'Suppress'}
|
||||
{savingSuppression ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
async function openSuppressDialog() {
|
||||
render(<VulnerabilityScanSheet scanId={1} onClose={() => {}} canManageSuppressions />);
|
||||
await waitFor(() => expect(screen.getByText('CVE-2026-1000')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTitle('Suppress this CVE'));
|
||||
await userEvent.click(screen.getByTitle('Triage finding'));
|
||||
}
|
||||
|
||||
async function pickSelect(label: string, optionName: string) {
|
||||
@@ -58,6 +58,12 @@ async function pickSelect(label: string, optionName: string) {
|
||||
}
|
||||
|
||||
describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
it('titles the dialog Triage finding and prefills the exact image pattern', async () => {
|
||||
await openSuppressDialog();
|
||||
expect(screen.getByRole('heading', { name: 'Triage finding' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Image pattern (optional)')).toHaveValue('img:1');
|
||||
});
|
||||
|
||||
it('requires an OpenVEX justification for a not-affected decision and clears it when switching away', async () => {
|
||||
await openSuppressDialog();
|
||||
|
||||
@@ -65,7 +71,7 @@ describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed unreachable code path.');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(toast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.');
|
||||
expect(postSuppressionCall()).toBeUndefined();
|
||||
|
||||
@@ -79,11 +85,12 @@ describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'False positive confirmed by vendor.');
|
||||
await pickSelect('Triage decision', 'False positive');
|
||||
await pickSelect('OpenVEX justification', 'Inline mitigations already exist');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(postSuppressionCall()).toBeTruthy());
|
||||
const body = JSON.parse((postSuppressionCall()![1] as { body: string }).body);
|
||||
expect(body.status).toBe('false_positive');
|
||||
expect(body.justification).toBe('inline_mitigations_already_exist');
|
||||
expect(body.image_pattern).toBe('img:1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
|
||||
import type { ImageExposureContext } from '@/types/security';
|
||||
|
||||
function openNetworking(nodeId: number | undefined, stackName: string) {
|
||||
if (nodeId === undefined) return;
|
||||
window.dispatchEvent(new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, {
|
||||
detail: { nodeId, stackName, destination: 'anatomy-networking' },
|
||||
}));
|
||||
}
|
||||
|
||||
function networkingActionLabel(ctx: ImageExposureContext): string {
|
||||
return ctx.intentConflict ? 'Review networking' : 'View networking';
|
||||
}
|
||||
|
||||
function NetworkingContextList({
|
||||
contexts,
|
||||
nodeId,
|
||||
showConflictHint = false,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId: number;
|
||||
showConflictHint?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{contexts.map((ctx) => (
|
||||
<li key={`${ctx.stackName}\0${ctx.serviceName}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40"
|
||||
onClick={() => openNetworking(nodeId, ctx.stackName)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-mono text-[11px] text-stat-value">
|
||||
{ctx.stackName}/{ctx.serviceName}
|
||||
</span>
|
||||
{showConflictHint && ctx.intentConflict ? (
|
||||
<span className="font-mono text-[10px] text-warning">Intent mismatch</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-brand whitespace-nowrap">
|
||||
{networkingActionLabel(ctx)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Network exposed badge + optional multi-context Networking popover. */
|
||||
export function NetworkExposedControl({
|
||||
contexts,
|
||||
nodeId,
|
||||
className,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const badge = (
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.14em] text-warning whitespace-nowrap', className)}>
|
||||
Network exposed
|
||||
</span>
|
||||
);
|
||||
|
||||
if (contexts.length === 0 || nodeId === undefined) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
if (contexts.length === 1) {
|
||||
const only = contexts[0]!;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openNetworking(nodeId, only.stackName);
|
||||
}}
|
||||
aria-label={`${networkingActionLabel(only)} for ${only.stackName}/${only.serviceName}`}
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="View networking contexts"
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2" onClick={(e) => e.stopPropagation()}>
|
||||
<NetworkingContextList contexts={contexts} nodeId={nodeId} showConflictHint />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Banner-only View networking control (single dispatch or multi popover). */
|
||||
export function ViewNetworkingAction({
|
||||
contexts,
|
||||
nodeId,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
}) {
|
||||
if (contexts.length === 0 || nodeId === undefined) return null;
|
||||
|
||||
if (contexts.length === 1) {
|
||||
const only = contexts[0]!;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0"
|
||||
onClick={() => openNetworking(nodeId, only.stackName)}
|
||||
>
|
||||
View networking
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0">
|
||||
View networking
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-2">
|
||||
<NetworkingContextList contexts={contexts} nodeId={nodeId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,8 +14,18 @@ import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { ImageScanRow, ImageFilterChips, type ImageFilterChip } from './SecurityMobile';
|
||||
import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
||||
|
||||
import { NetworkExposedControl, ViewNetworkingAction } from './ExposureNetworking';
|
||||
import type { ImagesTargetingState } from './imagesTargeting';
|
||||
import {
|
||||
intentionalBannerKind,
|
||||
standingExposureContexts,
|
||||
standingIntentEvidence,
|
||||
targetingExposureContexts,
|
||||
allTargetingExposureContexts,
|
||||
primaryExposureIntentEvidence,
|
||||
driverIdsForImage,
|
||||
} from './imagesTargeting';
|
||||
import type { ImageExposureContext, ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
||||
// Mobile severity chips. 'FIXABLE' is a phone-only pseudo-filter (the desktop
|
||||
// Combobox never emits it), so the shared filter logic treats it specially.
|
||||
const MOBILE_FILTER_CHIPS: ImageFilterChip[] = [
|
||||
@@ -66,24 +76,140 @@ const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [
|
||||
|
||||
const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0);
|
||||
|
||||
/** Intent evidence for standing summary, or targeting when active for this image. */
|
||||
function intentEvidenceFor(
|
||||
summary: ScanSummary,
|
||||
targeting: ImagesTargetingState | null | undefined,
|
||||
): string | null {
|
||||
if (targeting?.imageRefs.includes(summary.image_ref)) {
|
||||
const fromTargets = primaryExposureIntentEvidence(targeting.targets, summary.image_ref);
|
||||
if (fromTargets) return fromTargets;
|
||||
}
|
||||
return standingIntentEvidence(summary);
|
||||
}
|
||||
|
||||
function contextsForImage(
|
||||
summary: ScanSummary,
|
||||
targeting: ImagesTargetingState | null | undefined,
|
||||
): ImageExposureContext[] {
|
||||
if (targeting?.imageRefs.includes(summary.image_ref)) {
|
||||
const fromTargets = targetingExposureContexts(targeting.targets, summary.image_ref);
|
||||
if (fromTargets.length > 0) return fromTargets;
|
||||
}
|
||||
return standingExposureContexts(summary);
|
||||
}
|
||||
|
||||
function IntentEvidenceLine({ line }: { line: string | null }) {
|
||||
if (!line) return null;
|
||||
return (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-stat-icon truncate">{line}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CLEAR_BTN_CLASS =
|
||||
'text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0';
|
||||
|
||||
function TargetingClearButton({ onClear }: { onClear?: () => void }) {
|
||||
if (!onClear) return null;
|
||||
return (
|
||||
<button type="button" onClick={onClear} className={CLEAR_BTN_CLASS}>
|
||||
Clear
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetingBannerFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card/60 px-3 py-2.5 max-md:px-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTargetingTitle(label: string, matched: number, total: number): string {
|
||||
if (matched < total) {
|
||||
return `${label} · ${matched} of ${total} affected images`;
|
||||
}
|
||||
return `${label} · ${matched} affected image${matched === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function IntentionalExposureBanner({
|
||||
kind,
|
||||
unavailableCount,
|
||||
contexts,
|
||||
nodeId,
|
||||
onClear,
|
||||
}: {
|
||||
kind: 'absolute' | 'partial';
|
||||
unavailableCount: number;
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
const title = kind === 'absolute'
|
||||
? 'Exposure is intentional'
|
||||
: 'Known exposure is intentional';
|
||||
const body = kind === 'absolute'
|
||||
? 'This workload is classified in Networking. Exposure still increases the security relevance of these findings. Open an affected image below to remediate or triage its findings.'
|
||||
: `Known exposure contexts are intentionally classified. Intent could not be verified for ${unavailableCount} service${unavailableCount === 1 ? '' : 's'}. Open an affected image below to remediate or triage its findings.`;
|
||||
|
||||
return (
|
||||
<TargetingBannerFrame>
|
||||
<div className="flex items-start gap-3 justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs text-stat-value">{title}</p>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{body}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<ViewNetworkingAction contexts={contexts} nodeId={nodeId} />
|
||||
<TargetingClearButton onClear={onClear} />
|
||||
</div>
|
||||
</div>
|
||||
</TargetingBannerFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImagesTabProps {
|
||||
summaries: Record<string, ScanSummary>;
|
||||
loading: boolean;
|
||||
/** True when the summaries fetch failed; render an error state, never a false "clean". */
|
||||
error?: boolean;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void;
|
||||
/** Admin on a node with a ready scanner; gates the scan Actions column. */
|
||||
canScan: boolean;
|
||||
/** image_ref of the scan currently in flight, for the per-row spinner. */
|
||||
scanningRef: string | null;
|
||||
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
|
||||
/** Preselects the severity/fixable filter, e.g. when arriving from an
|
||||
* overview "fixable findings" link. Applied whenever the value changes. */
|
||||
* overview "fixable findings" link. */
|
||||
initialFilter?: ImageFilterValue;
|
||||
/** Bumped by SecurityView on each filter/targeting navigation so re-apply works. */
|
||||
filterToken?: number;
|
||||
/** Parent-owned posture targeting (R1). */
|
||||
targeting?: ImagesTargetingState | null;
|
||||
onClearTargeting?: () => void;
|
||||
/** When true, targeting banner discloses the overview pass may be incomplete. */
|
||||
posturePartial?: boolean;
|
||||
/** Active node id for SENCHO_OPEN_STACK Networking navigation. */
|
||||
nodeId?: number;
|
||||
}
|
||||
|
||||
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
||||
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter }: ImagesTabProps) {
|
||||
export function ImagesTab({
|
||||
summaries,
|
||||
loading,
|
||||
error,
|
||||
onInspect,
|
||||
canScan,
|
||||
scanningRef,
|
||||
onScan,
|
||||
initialFilter,
|
||||
filterToken = 0,
|
||||
targeting = null,
|
||||
onClearTargeting,
|
||||
posturePartial = false,
|
||||
nodeId,
|
||||
}: ImagesTabProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [search, setSearch] = useState('');
|
||||
const [severity, setSeverity] = useState<ImageFilterValue>(initialFilter ?? 'all');
|
||||
@@ -95,23 +221,57 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
|
||||
useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]);
|
||||
|
||||
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
|
||||
// Keyed on the incoming value so re-navigating to the same filter re-applies.
|
||||
// Apply externally-driven filter / targeting. Keyed on tokens so repeating the
|
||||
// same navigation re-applies after Clear (R1) and resets severity (R2).
|
||||
useEffect(() => {
|
||||
if (initialFilter) { setSeverity(initialFilter); setPage(0); }
|
||||
}, [initialFilter]);
|
||||
if (targeting) {
|
||||
setSeverity(initialFilter ?? 'all');
|
||||
setPage(0);
|
||||
return;
|
||||
}
|
||||
if (initialFilter) {
|
||||
setSeverity(initialFilter);
|
||||
setPage(0);
|
||||
}
|
||||
}, [targeting?.token, filterToken, targeting, initialFilter]);
|
||||
|
||||
const imageSummaries = useMemo(
|
||||
() => Object.values(summaries).filter((s) => !s.image_ref.startsWith('stack:')),
|
||||
[summaries],
|
||||
);
|
||||
|
||||
const matchedTargetMeta = useMemo(() => {
|
||||
if (!targeting || targeting.imageRefs.length === 0) {
|
||||
return { active: false as const, matched: 0, total: 0, refs: null as Set<string> | null };
|
||||
}
|
||||
const wanted = new Set(targeting.imageRefs);
|
||||
const refs = new Set(
|
||||
imageSummaries.filter((s) => wanted.has(s.image_ref)).map((s) => s.image_ref),
|
||||
);
|
||||
return {
|
||||
active: true as const,
|
||||
matched: refs.size,
|
||||
total: targeting.imageRefs.length,
|
||||
refs,
|
||||
label: targeting.label,
|
||||
};
|
||||
}, [targeting, imageSummaries]);
|
||||
|
||||
// R3: never filter the list at zero matches; fall back to the full list.
|
||||
const targetingActive = matchedTargetMeta.active && matchedTargetMeta.matched > 0;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return Object.values(summaries)
|
||||
.filter((s) => !s.image_ref.startsWith('stack:'))
|
||||
const targetRefs = targetingActive ? matchedTargetMeta.refs : null;
|
||||
return imageSummaries
|
||||
.filter((s) => (targetRefs ? targetRefs.has(s.image_ref) : true))
|
||||
.filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true))
|
||||
.filter((s) => {
|
||||
if (severity === 'all') return true;
|
||||
if (severity === 'FIXABLE') return s.fixable > 0;
|
||||
return getSeverityKey(s) === severity;
|
||||
});
|
||||
}, [summaries, search, severity]);
|
||||
}, [imageSummaries, search, severity, targetingActive, matchedTargetMeta.refs]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
@@ -155,19 +315,128 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
);
|
||||
}
|
||||
|
||||
const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:'));
|
||||
const noImagesAtAll = imageSummaries.length === 0;
|
||||
if (noImagesAtAll) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
||||
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
||||
<div className="space-y-4">
|
||||
{targeting && onClearTargeting ? (
|
||||
<TargetingBannerFrame>
|
||||
<div className="flex items-start gap-3 justify-between">
|
||||
<p className="text-xs text-stat-subtitle">
|
||||
None of the images for this Security action have a scan summary on this node.
|
||||
</p>
|
||||
<TargetingClearButton onClear={onClearTargeting} />
|
||||
</div>
|
||||
</TargetingBannerFrame>
|
||||
) : null}
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
||||
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let targetingBanner: ReactNode = null;
|
||||
if (matchedTargetMeta.active && matchedTargetMeta.matched === 0) {
|
||||
targetingBanner = (
|
||||
<TargetingBannerFrame>
|
||||
<div className="flex items-start gap-3 justify-between">
|
||||
<p className="text-xs text-stat-subtitle">
|
||||
None of the images for this Security action have a scan summary on this node. Showing all images.
|
||||
</p>
|
||||
<TargetingClearButton onClear={onClearTargeting} />
|
||||
</div>
|
||||
</TargetingBannerFrame>
|
||||
);
|
||||
} else if (matchedTargetMeta.active && matchedTargetMeta.matched > 0 && targeting) {
|
||||
const isExposure = targeting.kind === 'public_exposure';
|
||||
const hasConflict = isExposure && targeting.targets.some((t) => t.intentConflict);
|
||||
const driverCount = targeting.drivers?.length ?? 0;
|
||||
const intentional = isExposure && !hasConflict
|
||||
? intentionalBannerKind(targeting.targets, { truncated: posturePartial })
|
||||
: { kind: 'none' as const, unavailableCount: 0 };
|
||||
|
||||
if (hasConflict) {
|
||||
targetingBanner = (
|
||||
<TargetingBannerFrame>
|
||||
<div className="flex items-start gap-3 justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs text-stat-value">Exposure conflicts with declared intent</p>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">
|
||||
Compose publishes beyond loopback while Networking intent is internal or same-node. Review networking to align configuration with intent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<ViewNetworkingAction contexts={allTargetingExposureContexts(targeting.targets)} nodeId={nodeId} />
|
||||
<TargetingClearButton onClear={onClearTargeting} />
|
||||
</div>
|
||||
</div>
|
||||
</TargetingBannerFrame>
|
||||
);
|
||||
} else if (intentional.kind === 'absolute' || intentional.kind === 'partial') {
|
||||
targetingBanner = (
|
||||
<IntentionalExposureBanner
|
||||
kind={intentional.kind}
|
||||
unavailableCount={intentional.unavailableCount}
|
||||
contexts={allTargetingExposureContexts(targeting.targets)}
|
||||
nodeId={nodeId}
|
||||
onClear={onClearTargeting}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const partialMatch = matchedTargetMeta.matched < matchedTargetMeta.total;
|
||||
const fullDriverCount = targeting.driverCount ?? driverCount;
|
||||
const drivingTitle = driverCount > 0;
|
||||
const monitoringKinds = new Set(['waiting_upstream', 'update_check_uncertain']);
|
||||
const monitoringMode = monitoringKinds.has(targeting.kind);
|
||||
const truncated = targeting.driversTruncated === true
|
||||
&& fullDriverCount > driverCount
|
||||
&& driverCount > 0;
|
||||
const driverTitle = monitoringMode
|
||||
? (truncated
|
||||
? `Findings under Monitoring · showing ${driverCount} of ${fullDriverCount}`
|
||||
: `Findings under Monitoring · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`)
|
||||
: (truncated
|
||||
? `Driving current Security action · showing ${driverCount} of ${fullDriverCount}`
|
||||
: `Driving current Security action · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`);
|
||||
targetingBanner = (
|
||||
<TargetingBannerFrame>
|
||||
<div className="flex items-start gap-3 justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs text-stat-value">
|
||||
{drivingTitle
|
||||
? driverTitle
|
||||
: formatTargetingTitle(
|
||||
matchedTargetMeta.label,
|
||||
matchedTargetMeta.matched,
|
||||
matchedTargetMeta.total,
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">
|
||||
{drivingTitle
|
||||
? (monitoringMode
|
||||
? 'Open an image to review findings under Monitoring for this reason.'
|
||||
: 'Open an image to review the exact findings driving this Security action.')
|
||||
: 'Showing images responsible for the current Security action.'}
|
||||
{partialMatch ? ' An affected image has no scan summary on this node.' : ''}
|
||||
{posturePartial ? ' The overview pass may be incomplete.' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<TargetingClearButton onClear={onClearTargeting} />
|
||||
</div>
|
||||
</TargetingBannerFrame>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inspectDriversFor = (imageRef: string) => driverIdsForImage(targeting?.drivers, imageRef);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{targetingBanner}
|
||||
|
||||
{isMobile ? (
|
||||
<>
|
||||
{search !== '' || searchExpanded ? (
|
||||
@@ -202,7 +471,15 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="px-4">
|
||||
{pageItems.map((s) => (
|
||||
<ImageScanRow key={s.image_ref} summary={s} onInspect={onInspect} />
|
||||
<ImageScanRow
|
||||
key={s.image_ref}
|
||||
summary={s}
|
||||
onInspect={onInspect}
|
||||
driverVulnerabilityIds={inspectDriversFor(s.image_ref)}
|
||||
intentEvidence={intentEvidenceFor(s, targeting)}
|
||||
exposureContexts={contextsForImage(s, targeting)}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pageItems.length === 0 && (
|
||||
@@ -263,14 +540,23 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
{pageItems.map((s) => (
|
||||
<TableRow key={s.image_ref} className="hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-mono text-xs truncate max-w-[280px]">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button type="button" className="hover:text-brand truncate block text-left min-w-0" onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
{s.publicly_exposed === true ? (
|
||||
<NetworkExposedControl
|
||||
contexts={contextsForImage(s, targeting)}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<IntentEvidenceLine line={intentEvidenceFor(s, targeting)} />
|
||||
</TableCell>
|
||||
<TableCell className="max-md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInspect(s.scan_id, 'vulns')}
|
||||
onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))}
|
||||
className="font-mono tabular-nums text-xs text-stat-subtitle text-left hover:text-stat-value transition-colors"
|
||||
>
|
||||
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
||||
@@ -285,7 +571,7 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
{formatTimeAgo(s.scanned_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
||||
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))} />
|
||||
</TableCell>
|
||||
{canScan && (
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
|
||||
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
import { reasonImageFilter } from './postureNavigation';
|
||||
import { reasonImageFilter, defaultReasonActionLabel } from './postureNavigation';
|
||||
import { triggerNodeImageUpdateCheck } from './imageUpdateRecheck';
|
||||
import { targetingFromTargets, type ImagesTargetingInput } from './imagesTargeting';
|
||||
import {
|
||||
RiskTrendChart,
|
||||
ActionPostureChart,
|
||||
@@ -17,8 +21,13 @@ import {
|
||||
} from './SecurityCharts';
|
||||
import { ScanNodeLauncher } from './ScanNodeLauncher';
|
||||
|
||||
/** Navigate to a security tab, optionally preselecting an Images filter. */
|
||||
type NavigateFn = (tab: SecurityTab, filter?: ImageFilterValue) => void;
|
||||
/** Navigate to a security tab, optionally with an Images severity filter and/or
|
||||
* posture targeting (image refs). */
|
||||
type NavigateFn = (
|
||||
tab: SecurityTab,
|
||||
filter?: ImageFilterValue,
|
||||
targeting?: ImagesTargetingInput,
|
||||
) => void;
|
||||
|
||||
interface OverviewTabProps {
|
||||
overview: SecurityOverview | null;
|
||||
@@ -35,6 +44,8 @@ interface OverviewTabProps {
|
||||
canScan: boolean;
|
||||
/** Refresh the overview after a node-wide scan completes. */
|
||||
onScanComplete: () => void;
|
||||
/** Whether the operator may trigger node-scoped image-update refresh. */
|
||||
canManageNode?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
|
||||
@@ -74,62 +85,144 @@ const SEVERITY_LABEL: Record<PostureReason['severity'], string> = {
|
||||
info: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
function reasonNavLabel(r: PostureReason): string {
|
||||
return `${r.actionLabel ?? defaultReasonActionLabel(r.targetTab)} →`;
|
||||
}
|
||||
|
||||
function navigateReason(onNavigate: NavigateFn, reason: PostureReason): void {
|
||||
const targeting = targetingFromTargets(
|
||||
reason.kind,
|
||||
reason.label,
|
||||
reason.targets,
|
||||
reason.drivers,
|
||||
{ driverCount: reason.driverCount, driversTruncated: reason.driversTruncated },
|
||||
);
|
||||
// Prefer precise targets; severity filter is only the older-node fallback.
|
||||
const filter = targeting ? undefined : reasonImageFilter(reason.kind);
|
||||
onNavigate(reason.targetTab, filter, targeting);
|
||||
}
|
||||
|
||||
function ReasonRow({
|
||||
reason,
|
||||
onNavigate,
|
||||
showCheckAgain = false,
|
||||
checkAgainBusy = false,
|
||||
onCheckAgain,
|
||||
}: {
|
||||
reason: PostureReason;
|
||||
onNavigate: NavigateFn;
|
||||
showCheckAgain?: boolean;
|
||||
checkAgainBusy?: boolean;
|
||||
onCheckAgain?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[reason.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[reason.severity])}>{reason.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{reason.count}</span>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{showCheckAgain && onCheckAgain ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkAgainBusy}
|
||||
onClick={onCheckAgain}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
{checkAgainBusy ? 'Starting…' : 'Check again'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateReason(onNavigate, reason)}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
|
||||
>
|
||||
{reasonNavLabel(reason)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{reason.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewQueueCard({
|
||||
reasons,
|
||||
onNavigate,
|
||||
canManageNode,
|
||||
updateChecksDisabled,
|
||||
posture,
|
||||
}: {
|
||||
reasons: PostureReason[];
|
||||
onNavigate: NavigateFn;
|
||||
canManageNode: boolean;
|
||||
updateChecksDisabled: boolean;
|
||||
posture?: SecurityOverview['posture'];
|
||||
}) {
|
||||
const [checkAgainBusy, setCheckAgainBusy] = useState(false);
|
||||
const blockers = reasons.filter((r) => r.severity === 'blocker');
|
||||
const nonBlockers = reasons.filter((r) => r.severity !== 'blocker');
|
||||
const hasBlockers = blockers.length > 0;
|
||||
const title = hasBlockers ? 'Why Action needed' : 'Review queue';
|
||||
const title = hasBlockers
|
||||
? 'Why Action needed'
|
||||
: posture === 'Monitoring'
|
||||
? 'Why Monitoring'
|
||||
: 'Review queue';
|
||||
|
||||
const handleCheckAgain = async () => {
|
||||
if (checkAgainBusy) return;
|
||||
setCheckAgainBusy(true);
|
||||
try {
|
||||
await triggerNodeImageUpdateCheck();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to start image update check');
|
||||
} finally {
|
||||
setCheckAgainBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showCheckAgainFor = (r: PostureReason): boolean =>
|
||||
r.kind === 'update_check_uncertain' && canManageNode && !updateChecksDisabled;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
|
||||
<div className="space-y-3">
|
||||
{blockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(r.targetTab, reasonImageFilter(r.kind))}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap ml-auto"
|
||||
>
|
||||
Open {r.targetTab === 'compose' ? 'Compose risks' : r.targetTab === 'suppressions' ? 'Suppressions' : r.targetTab === 'secrets' ? 'Secrets' : r.targetTab === 'history' ? 'History' : r.targetTab === 'scanner' ? 'Scanner setup' : 'Images'} →
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ReasonRow key={`${r.kind}-${i}`} reason={r} onNavigate={onNavigate} />
|
||||
))}
|
||||
{nonBlockers.length > 0 && hasBlockers && (
|
||||
<div className="border-t border-hairline pt-3 mt-1" />
|
||||
)}
|
||||
{nonBlockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ReasonRow
|
||||
key={`${r.kind}-${i}`}
|
||||
reason={r}
|
||||
onNavigate={onNavigate}
|
||||
showCheckAgain={showCheckAgainFor(r)}
|
||||
checkAgainBusy={checkAgainBusy}
|
||||
onCheckAgain={handleCheckAgain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
|
||||
export function OverviewTab({
|
||||
overview,
|
||||
loadError,
|
||||
trend,
|
||||
exploitIntel,
|
||||
exploitTruncated,
|
||||
onNavigate,
|
||||
onInspect,
|
||||
canScan,
|
||||
onScanComplete,
|
||||
canManageNode = false,
|
||||
}: OverviewTabProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (loadError === 'unsupported') {
|
||||
@@ -220,6 +313,9 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
|
||||
<ReviewQueueCard
|
||||
reasons={overview.postureReasons}
|
||||
onNavigate={onNavigate}
|
||||
canManageNode={canManageNode}
|
||||
updateChecksDisabled={overview.updateChecksDisabled === true}
|
||||
posture={overview.posture}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -288,7 +384,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
|
||||
tone="subtitle"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
|
||||
Security posture describes current operational actionability. Explicit deploy policies may enforce stricter admission rules (package fix available ≠ confirmed image update). Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +374,7 @@ export function ScanPolicyManager() {
|
||||
)}
|
||||
{policy.block_on_fixable === 1 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
Fixable
|
||||
Package fix
|
||||
</Badge>
|
||||
)}
|
||||
{policy.block_on_deploy === 1 && (
|
||||
@@ -501,11 +501,11 @@ export function ScanPolicyManager() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Fixable Critical/High</Label>
|
||||
<p className="text-xs text-muted-foreground">Flag an image with a Critical or High finding that has a fix available.</p>
|
||||
<Label className="text-sm">Package fix available (Critical/High)</Label>
|
||||
<p className="text-xs text-muted-foreground">Uses the scanner fixed_version for Critical or High findings. This is not a confirmed container-image update.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Fixable Critical/High"
|
||||
aria-label="Package fix available (Critical/High)"
|
||||
checked={form.block_on_fixable}
|
||||
onChange={(c) => setForm({ ...form, block_on_fixable: c })}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
|
||||
import type { ImageExposureContext, ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
|
||||
import { NetworkExposedControl } from './ExposureNetworking';
|
||||
|
||||
export interface SecurityMobileTab {
|
||||
value: SecurityTab;
|
||||
@@ -122,9 +123,23 @@ function CountTag({ tone, children }: { tone: keyof typeof COUNT_TAG_TONE; child
|
||||
|
||||
/** One image row in the mobile Images list: severity dot, truncated mono ref over
|
||||
* a freshness meta line, trailing C/H count tags (or CLEAN), and a chevron. */
|
||||
export function ImageScanRow({ summary, onInspect }: {
|
||||
export function ImageScanRow({
|
||||
summary,
|
||||
onInspect,
|
||||
driverVulnerabilityIds,
|
||||
intentEvidence = null,
|
||||
exposureContexts = [],
|
||||
nodeId,
|
||||
}: {
|
||||
summary: ScanSummary;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void;
|
||||
/** Per-image driving finding ids from posture targeting. */
|
||||
driverVulnerabilityIds?: string[];
|
||||
/** Compact exposure-intent line (standing or while posture-targeting). */
|
||||
intentEvidence?: string | null;
|
||||
/** Stack/service contexts for Networking navigation from Network exposed. */
|
||||
exposureContexts?: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
}) {
|
||||
// Use the shared classifier so the count tags agree with the leading dot: a
|
||||
// medium/low-only image is not "clean", it is its highest severity.
|
||||
@@ -133,13 +148,19 @@ export function ImageScanRow({ summary, onInspect }: {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInspect(summary.scan_id, 'vulns')}
|
||||
onClick={() => onInspect(summary.scan_id, 'vulns', driverVulnerabilityIds)}
|
||||
className="flex min-h-11 w-full items-center gap-[11px] border-b border-hairline py-[11px] text-left last:border-b-0"
|
||||
>
|
||||
<span className={cn('h-[7px] w-[7px] shrink-0 rounded-full', SEVERITY_DOT_CLASSES[severityKey])} aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono text-[13px] text-stat-value">{summary.image_ref}</span>
|
||||
<span className="mt-px block font-mono text-[10px] text-stat-icon">scanned {formatTimeAgo(summary.scanned_at)}</span>
|
||||
<span className="mt-px flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[10px] text-stat-icon">
|
||||
<span>scanned {formatTimeAgo(summary.scanned_at)}</span>
|
||||
{summary.publicly_exposed === true ? (
|
||||
<NetworkExposedControl contexts={exposureContexts} nodeId={nodeId} />
|
||||
) : null}
|
||||
{intentEvidence ? <span className="normal-case tracking-normal">{intentEvidence}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{summary.critical > 0 && <CountTag tone="destructive">{summary.critical}C</CountTag>}
|
||||
|
||||
@@ -66,7 +66,7 @@ it('opens the scan sheet on the vulns tab from the image name', async () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('nginx:1'));
|
||||
expect(onInspect).toHaveBeenCalledWith(7, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(7, 'vulns', undefined);
|
||||
});
|
||||
|
||||
it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
|
||||
@@ -79,7 +79,7 @@ it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('clean'));
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns', undefined);
|
||||
});
|
||||
|
||||
it('narrows the list with the search box', async () => {
|
||||
@@ -136,3 +136,367 @@ it('shows the scan action only when scanning is allowed', () => {
|
||||
rerender(<ImagesTab {...base} canScan={true} summaries={data} />);
|
||||
expect(screen.getByLabelText('Scan nginx:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to posture targets and shows a clearable banner', async () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={onClear}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'unset',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Network-exposed images not yet classified · 1 affected image/)).toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: not classified')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows standing intent evidence without targeting', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={7}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [{
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
exposureReason: 'published-port',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Network exposed only for mixed-version payloads without contexts', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Intent:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows absolute intentional targeting banner with View networking only', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Exposure is intentional')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Review findings/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Network-exposed images not yet classified ·/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows partial intentional targeting banner copy', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [
|
||||
{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'lan',
|
||||
},
|
||||
{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'worker',
|
||||
intentStatus: 'unavailable',
|
||||
},
|
||||
],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Known exposure is intentional')).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be verified for 1 service/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows driver-focused banner for elevated_exploit_risk targeting', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'exp:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'exp:1' },
|
||||
],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Driving current Security action · 2 findings/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/exact findings driving this Security action/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Elevated exploit risk on network-exposed workload ·/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Monitoring findings banner with truncation when waiting_upstream drivers are capped', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
imageRefs: ['app:1'],
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'app:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'app:1' },
|
||||
],
|
||||
driverCount: 9,
|
||||
driversTruncated: true,
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'app:1', scan_id: 1 }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Findings under Monitoring · showing 2 of 9/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it('shows conflict banner for public_exposure intent mismatch', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Exposure conflicts with declared intent',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'internal',
|
||||
intentConflict: true,
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Exposure conflicts with declared intent')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Review networking to align configuration with intent/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows matched of total when a target has no summary', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['a:1', 'b:1', 'missing:1'],
|
||||
targets: ['a:1', 'b:1', 'missing:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/2 of 3 affected images/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no scan summary on this node/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not change the banner count when searching within the targeted set', async () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['a:1', 'b:1'],
|
||||
targets: ['a:1', 'b:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/· 2 affected images/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText('Search images'));
|
||||
await userEvent.type(screen.getByPlaceholderText('Search images...'), 'a:');
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('b:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/· 2 affected images/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-applies targeting when the token increments after Clear', () => {
|
||||
const data = asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 1 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={null}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('other:1')).toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 2 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets a stale FIXABLE filter when targeting arrives without a filter', () => {
|
||||
const data = asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, fixable: 0, highest_severity: 'HIGH', high: 1, total: 1 }),
|
||||
summary({ image_ref: 'fix:1', scan_id: 2, fixable: 2, highest_severity: 'HIGH', high: 2, total: 2 }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<ImagesTab {...base} initialFilter="FIXABLE" filterToken={1} summaries={data} />,
|
||||
);
|
||||
expect(screen.getByText('fix:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('exp:1')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
initialFilter={undefined}
|
||||
filterToken={2}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 1 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back without a banner when targets are missing', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/affected image/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('b:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a clearable zero-match note and keeps the full list', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['missing:1'],
|
||||
targets: ['missing:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/scan summary on this node/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('b:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* OverviewTab review-queue affordances for remediation-aware posture:
|
||||
* non-blocker View findings, Check again gating, and node-scoped refresh.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { SecurityOverview, PostureReason } from '@/types/security';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('../ScanNodeLauncher', () => ({ ScanNodeLauncher: () => null }));
|
||||
vi.mock('../SecurityCharts', () => ({
|
||||
RiskTrendChart: () => null,
|
||||
ActionPostureChart: () => null,
|
||||
TopExploitRiskList: () => null,
|
||||
CvssEpssQuadrantChart: () => null,
|
||||
}));
|
||||
vi.mock('../SecurityMobile', () => ({
|
||||
SecuritySevStrip: () => null,
|
||||
SecurityTotalsGrid: () => null,
|
||||
SecurityFooterBand: () => null,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
type OverviewNavigate = ComponentProps<typeof OverviewTab>['onNavigate'];
|
||||
|
||||
function reason(partial: Partial<PostureReason> & Pick<PostureReason, 'kind' | 'label'>): PostureReason {
|
||||
return {
|
||||
count: 2,
|
||||
severity: 'info',
|
||||
description: 'test description',
|
||||
targetTab: 'images',
|
||||
actionLabel: 'View findings',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(reasons: PostureReason[], extra: Partial<SecurityOverview> = {}): SecurityOverview {
|
||||
return {
|
||||
scannedImages: 1,
|
||||
critical: 1,
|
||||
high: 0,
|
||||
fixable: 1,
|
||||
secrets: 0,
|
||||
misconfigs: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
lastSuccessfulScanAt: Date.now(),
|
||||
scanner: { available: true, version: '0.50.0', source: 'managed', autoUpdate: true },
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: true, eligibleBlockPolicies: 0 },
|
||||
posture: 'Monitoring',
|
||||
postureReasons: reasons,
|
||||
primaryAction: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function renderOverview(
|
||||
reasons: PostureReason[],
|
||||
opts: {
|
||||
canManageNode?: boolean;
|
||||
updateChecksDisabled?: boolean;
|
||||
onNavigate?: OverviewNavigate;
|
||||
} = {},
|
||||
) {
|
||||
const onNavigate: OverviewNavigate = opts.onNavigate ?? vi.fn();
|
||||
render(
|
||||
<OverviewTab
|
||||
overview={overview(reasons, { updateChecksDisabled: opts.updateChecksDisabled })}
|
||||
loadError={null}
|
||||
trend={[]}
|
||||
exploitIntel={[]}
|
||||
exploitTruncated={false}
|
||||
onNavigate={onNavigate}
|
||||
onInspect={vi.fn()}
|
||||
canScan={false}
|
||||
onScanComplete={vi.fn()}
|
||||
canManageNode={opts.canManageNode ?? false}
|
||||
/>,
|
||||
);
|
||||
return { onNavigate };
|
||||
}
|
||||
|
||||
describe('OverviewTab remediation affordances', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('titles the review queue Why Monitoring when posture is Monitoring without blockers', () => {
|
||||
renderOverview([
|
||||
reason({ kind: 'waiting_upstream', label: 'Waiting for upstream image', severity: 'review' }),
|
||||
]);
|
||||
expect(screen.getByRole('heading', { name: /why monitoring/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes waiting_upstream driver meta into Images targeting', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
severity: 'review',
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
driverCount: 5,
|
||||
driversTruncated: true,
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /view findings/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
imageRefs: ['app:1'],
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
driverCount: 5,
|
||||
driversTruncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders View findings on a waiting_upstream non-blocker row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({ kind: 'waiting_upstream', label: 'Waiting for upstream image' }),
|
||||
]);
|
||||
const btn = screen.getByRole('button', { name: /view findings/i });
|
||||
await user.click(btn);
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, undefined);
|
||||
});
|
||||
|
||||
it('passes public_exposure targets from the review queue', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
severity: 'review',
|
||||
actionLabel: 'Review networking',
|
||||
targets: [{ imageRef: 'exp:1' }, { imageRef: 'exp:2' }],
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /review networking/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1', 'exp:2'],
|
||||
targets: [{ imageRef: 'exp:1' }, { imageRef: 'exp:2' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes elevated_exploit_risk drivers into Images targeting', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
severity: 'blocker',
|
||||
actionLabel: 'Review driving findings',
|
||||
targets: [{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /review driving findings/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
imageRefs: ['web:1'],
|
||||
targets: [{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Check again for update_check_uncertain when canManageNode and checks enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, message: 'Image update check started in background.' }),
|
||||
});
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true, updateChecksDisabled: false },
|
||||
);
|
||||
const checkAgain = screen.getByRole('button', { name: /check again/i });
|
||||
await user.click(checkAgain);
|
||||
await waitFor(() => {
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/image-updates/refresh', { method: 'POST' });
|
||||
});
|
||||
expect(mockedFetch.mock.calls[0][1]).not.toMatchObject({ localOnly: true });
|
||||
expect(toast.success).toHaveBeenCalledWith('Image update check started in background.');
|
||||
});
|
||||
|
||||
it('hides Check again without node:manage', () => {
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: false },
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /check again/i })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /view findings/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides Check again when update checks are disabled', () => {
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true, updateChecksDisabled: true },
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /check again/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces 429 cooldown via toast.warning', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: async () => ({ error: 'Rate limited. Please wait at least 5 minutes between manual refreshes.' }),
|
||||
});
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true },
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /check again/i }));
|
||||
await waitFor(() => {
|
||||
expect(toast.warning).toHaveBeenCalledWith(expect.stringMatching(/rate limited/i));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ const riskPolicy = {
|
||||
replicated_from_control: 0, created_at: 1, updated_at: 1,
|
||||
};
|
||||
|
||||
it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
|
||||
it('renders a per-input badge for each active input (KEV/Package fix, no severity)', async () => {
|
||||
setup();
|
||||
mockedFetch.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])),
|
||||
@@ -77,7 +77,7 @@ it('renders a per-input badge for each active input (KEV/Fixable, no severity)',
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('risk-gate')).toBeInTheDocument());
|
||||
expect(screen.getByText('KEV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fixable')).toBeInTheDocument();
|
||||
expect(screen.getByText('Package fix')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^max:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ it('blocks a save that turns on block-on-deploy with no active input', async ()
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Known-exploited (KEV)' })); // KEV off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Fixable Critical/High' })); // fixable off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Package fix available (Critical/High)' })); // fixable off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Block on deploy' })); // block-on-deploy on
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('ImageScanRow', () => {
|
||||
const onInspect = vi.fn();
|
||||
render(<ImageScanRow summary={summary({ image_ref: 'redis:7', scan_id: 9 })} onInspect={onInspect} />);
|
||||
await userEvent.click(screen.getByText('redis:7'));
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,4 +186,85 @@ describe('ImagesTab (mobile)', () => {
|
||||
expect(screen.getByText('fix:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('nofix:1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the targeting banner and Clear on the phone layout', async () => {
|
||||
installMatchMedia(true);
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={onClear}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'unset',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true, critical: 2 }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Network-exposed images not yet classified · 1 affected image/)).toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: not classified')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows standing intent on the phone layout without targeting', () => {
|
||||
installMatchMedia(true);
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={2}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
critical: 1,
|
||||
exposure_contexts: [{
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
exposureReason: 'published-port',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'lan',
|
||||
}],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: LAN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps mixed-version Network exposed without inventing intent on phone', () => {
|
||||
installMatchMedia(true);
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
critical: 1,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Intent:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ it('reads Monitoring/warn when criticals/highs exist but nothing is actionable',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads Secure/live when a scan completed and nothing is actionable or severe', () => {
|
||||
it('reads Secure/live when a scan completed and nothing is residual or actionable', () => {
|
||||
expect(deriveMasthead(overview({}), false)).toEqual({ state: 'Secure', tone: 'live' });
|
||||
});
|
||||
|
||||
@@ -75,6 +75,11 @@ it('prefers the backend posture over the local bootstrap when present', () => {
|
||||
state: 'Monitoring',
|
||||
tone: 'warn',
|
||||
});
|
||||
// Bootstrap would read Monitoring from raw Crit/High; cleared-residual Secure wins.
|
||||
expect(deriveMasthead(overview({ critical: 5, high: 2, posture: 'Secure' }), false)).toEqual({
|
||||
state: 'Secure',
|
||||
tone: 'live',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the local bootstrap when the node reports no posture', () => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
targetingFromTargets,
|
||||
primaryExposureIntentEvidence,
|
||||
standingIntentEvidence,
|
||||
intentionalBannerKind,
|
||||
} from '../imagesTargeting';
|
||||
import type { ImageExposureContext, PostureTarget, ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(o: Partial<ScanSummary> & { image_ref?: string } = {}): ScanSummary {
|
||||
return {
|
||||
image_ref: 'nginx:1',
|
||||
highest_severity: null,
|
||||
scanned_at: 1,
|
||||
scan_id: 1,
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(partial: Partial<ImageExposureContext> & Pick<ImageExposureContext, 'stackName' | 'serviceName' | 'intentStatus'>): ImageExposureContext {
|
||||
return {
|
||||
exposureReason: 'published-port',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('targetingFromTargets', () => {
|
||||
it('derives unique imageRefs while keeping full targets', () => {
|
||||
const targets: PostureTarget[] = [
|
||||
{ imageRef: 'a:1', stackName: 's1', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', stackName: 's2', serviceName: 'api', intentStatus: 'unset' },
|
||||
{ imageRef: 'b:1', intentStatus: 'set', exposureIntent: 'lan' },
|
||||
];
|
||||
const input = targetingFromTargets(
|
||||
'public_exposure',
|
||||
'Network-exposed images not yet classified',
|
||||
targets,
|
||||
);
|
||||
expect(input?.imageRefs).toEqual(['a:1', 'b:1']);
|
||||
expect(input?.targets).toHaveLength(3);
|
||||
expect(input?.drivers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('copies drivers when present', () => {
|
||||
const drivers = [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
];
|
||||
const input = targetingFromTargets(
|
||||
'elevated_exploit_risk',
|
||||
'Elevated exploit risk on network-exposed workload',
|
||||
[{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers,
|
||||
);
|
||||
expect(input?.drivers).toEqual(drivers);
|
||||
});
|
||||
|
||||
it('copies driverCount and driversTruncated when provided', () => {
|
||||
const input = targetingFromTargets(
|
||||
'waiting_upstream',
|
||||
'Waiting for upstream image',
|
||||
[{ imageRef: 'app:1' }],
|
||||
[{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
{ driverCount: 12, driversTruncated: true },
|
||||
);
|
||||
expect(input?.driverCount).toBe(12);
|
||||
expect(input?.driversTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('omits drivers when empty or absent', () => {
|
||||
const noDrivers = targetingFromTargets(
|
||||
'public_exposure',
|
||||
'Exposure conflicts with declared intent',
|
||||
[{ imageRef: 'a:1', intentConflict: true, intentStatus: 'set', exposureIntent: 'internal' }],
|
||||
);
|
||||
expect(noDrivers?.drivers).toBeUndefined();
|
||||
expect(
|
||||
targetingFromTargets('elevated_exploit_risk', 'x', [{ imageRef: 'a:1' }], [])
|
||||
?.drivers,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty targets', () => {
|
||||
expect(targetingFromTargets('public_exposure', 'x', [])).toBeUndefined();
|
||||
expect(targetingFromTargets('public_exposure', 'x', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryExposureIntentEvidence', () => {
|
||||
it('formats public intent', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
'a:1',
|
||||
)).toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('prefers mismatch over intentional', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'internal', intentConflict: true },
|
||||
],
|
||||
'a:1',
|
||||
)).toBe('Intent mismatch: internal (+1)');
|
||||
});
|
||||
|
||||
it('formats unset classification', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'unset' }],
|
||||
'a:1',
|
||||
)).toBe('Intent: not classified');
|
||||
});
|
||||
|
||||
it('omits intent line when unavailable only', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'unavailable' }],
|
||||
'a:1',
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles legacy imageRef-only targets safely', () => {
|
||||
expect(primaryExposureIntentEvidence([{ imageRef: 'a:1' }], 'a:1')).toBeNull();
|
||||
expect(targetingFromTargets('fixable_cve', 'Newer image available', [{ imageRef: 'a:1' }])?.imageRefs)
|
||||
.toEqual(['a:1']);
|
||||
});
|
||||
|
||||
it('does not flatten multi-intent same image to a single false intent', () => {
|
||||
const line = primaryExposureIntentEvidence(
|
||||
[
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'unset' },
|
||||
],
|
||||
'a:1',
|
||||
);
|
||||
expect(line).toBe('Intent: not classified (+1)');
|
||||
expect(line).not.toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('accepts standing ImageExposureContext rows without imageRef', () => {
|
||||
expect(primaryExposureIntentEvidence([
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'lan' }),
|
||||
])).toBe('Intent: LAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standingIntentEvidence', () => {
|
||||
it('returns null for mixed-version publicly_exposed without contexts', () => {
|
||||
expect(standingIntentEvidence(summary({ publicly_exposed: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it('formats standing intent from contexts and summary flags', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('prefers summary conflict over intentional display context', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_context_count: 2,
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: true,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: false,
|
||||
},
|
||||
}))).toBe('Intent mismatch: internal (+1)');
|
||||
});
|
||||
|
||||
it('includes truncated remainder in +N', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'unset' }),
|
||||
],
|
||||
exposure_context_count: 5,
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: true,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: false,
|
||||
},
|
||||
}))).toBe('Intent: not classified (+4)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('intentionalBannerKind', () => {
|
||||
it('marks absolute intentional targets', () => {
|
||||
expect(intentionalBannerKind([
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'lan' },
|
||||
])).toEqual({ kind: 'absolute', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('marks partial when unavailable remains among intentional contexts', () => {
|
||||
expect(intentionalBannerKind([
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'unavailable' },
|
||||
])).toEqual({ kind: 'partial', unavailableCount: 1 });
|
||||
});
|
||||
|
||||
it('returns none for mixed-version summary without contexts', () => {
|
||||
expect(intentionalBannerKind(summary({ publicly_exposed: true }))).toEqual({
|
||||
kind: 'none',
|
||||
unavailableCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks absolute when truncated even if display contexts look intentional', () => {
|
||||
expect(intentionalBannerKind(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toEqual({ kind: 'none', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('blocks absolute when posture targeting attach was truncated', () => {
|
||||
expect(intentionalBannerKind(
|
||||
[{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
{ truncated: true },
|
||||
)).toEqual({ kind: 'none', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('uses summary flags for partial standing intentional', () => {
|
||||
expect(intentionalBannerKind(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
ctx({ stackName: 'web', serviceName: 'worker', intentStatus: 'unavailable' }),
|
||||
],
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: true,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toEqual({ kind: 'partial', unavailableCount: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reasonImageFilter } from '../postureNavigation';
|
||||
import { reasonImageFilter, defaultReasonActionLabel } from '../postureNavigation';
|
||||
import type { PostureReasonKind } from '@/types/security';
|
||||
|
||||
describe('reasonImageFilter', () => {
|
||||
it('maps fixable findings to the FIXABLE image filter', () => {
|
||||
it('maps confirmed image-update findings to the FIXABLE image filter', () => {
|
||||
expect(reasonImageFilter('fixable_cve')).toBe('FIXABLE');
|
||||
});
|
||||
|
||||
it('returns undefined for kinds with no per-image flag (opens Images unfiltered)', () => {
|
||||
const others: PostureReasonKind[] = [
|
||||
'waiting_upstream',
|
||||
'update_check_uncertain',
|
||||
'known_exploited',
|
||||
'elevated_exploit_risk',
|
||||
'secret',
|
||||
'dangerous_compose',
|
||||
'public_exposure',
|
||||
@@ -22,3 +25,11 @@ describe('reasonImageFilter', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultReasonActionLabel', () => {
|
||||
it('labels common security tabs', () => {
|
||||
expect(defaultReasonActionLabel('images')).toBe('Open Images');
|
||||
expect(defaultReasonActionLabel('compose')).toBe('Open Compose risks');
|
||||
expect(defaultReasonActionLabel('secrets')).toBe('Open Secrets');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
/** Node-scoped image-update refresh. Confirm via toast; do not refetch overview
|
||||
* immediately (the check runs in the background). */
|
||||
export async function triggerNodeImageUpdateCheck(): Promise<void> {
|
||||
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
||||
const body = await res.json().catch(() => ({})) as { error?: string; message?: string };
|
||||
if (res.status === 429) {
|
||||
toast.warning(body.error || 'Rate limited. Please wait before checking again.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) {
|
||||
toast.warning(body.error || 'Image update detection is disabled for this node.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || 'Failed to start image update check');
|
||||
}
|
||||
toast.success(body.message || 'Image update check started in background.');
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import type {
|
||||
ImageExposureContext,
|
||||
ImageExposureContextSummary,
|
||||
PostureDriverFinding,
|
||||
PostureReasonKind,
|
||||
PostureTarget,
|
||||
ScanSummary,
|
||||
} from '@/types/security';
|
||||
|
||||
/** Parent-owned Images drill-down from a posture reason/action. */
|
||||
export interface ImagesTargetingState {
|
||||
kind: PostureReasonKind;
|
||||
label: string;
|
||||
/** Unique image refs used for list filtering. */
|
||||
imageRefs: string[];
|
||||
/** Full target rows (may repeat imageRef across stack/service). */
|
||||
targets: PostureTarget[];
|
||||
/**
|
||||
* Exact contributing findings when the reason carries drivers.
|
||||
* Scoped per image when opening the scan sheet.
|
||||
*/
|
||||
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;
|
||||
/** Monotonic token so re-navigating the same reason re-applies after Clear. */
|
||||
token: number;
|
||||
}
|
||||
|
||||
/** Payload passed into navigate before SecurityView assigns a token. */
|
||||
export type ImagesTargetingInput = Omit<ImagesTargetingState, 'token'>;
|
||||
|
||||
/** Intent fields shared by posture targets and standing exposure contexts. */
|
||||
export type ExposureIntentSource =
|
||||
| ImageExposureContext
|
||||
| Pick<PostureTarget, 'exposureIntent' | 'intentStatus' | 'intentConflict' | 'imageRef'>;
|
||||
|
||||
/** Intents that mean the operator deliberately classified exposure. */
|
||||
const INTENTIONAL = new Set(['public', 'lan', 'reverse-proxy', 'temporary']);
|
||||
|
||||
function uniqueImageRefs(targets: PostureTarget[]): string[] {
|
||||
return [...new Set(targets.map((t) => t.imageRef))];
|
||||
}
|
||||
|
||||
/** Driver CVE/GHSA ids for one image (omit when none match so the sheet stays unfiltered). */
|
||||
export function driverIdsForImage(
|
||||
drivers: PostureDriverFinding[] | undefined,
|
||||
imageRef: string,
|
||||
): string[] | undefined {
|
||||
if (!drivers || drivers.length === 0) return undefined;
|
||||
const ids = [...new Set(
|
||||
drivers.filter((d) => d.imageRef === imageRef).map((d) => d.vulnerabilityId),
|
||||
)];
|
||||
return ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
/** Build Images targeting from a posture reason/action target list. */
|
||||
export function targetingFromTargets(
|
||||
kind: PostureReasonKind,
|
||||
label: string,
|
||||
targets: PostureTarget[] | undefined,
|
||||
drivers?: PostureDriverFinding[],
|
||||
driverMeta?: { driverCount?: number; driversTruncated?: boolean },
|
||||
): ImagesTargetingInput | undefined {
|
||||
if (!targets || targets.length === 0) return undefined;
|
||||
return {
|
||||
kind,
|
||||
label,
|
||||
imageRefs: uniqueImageRefs(targets),
|
||||
targets,
|
||||
...(drivers && drivers.length > 0 ? { drivers } : {}),
|
||||
...(driverMeta?.driverCount !== undefined ? { driverCount: driverMeta.driverCount } : {}),
|
||||
...(driverMeta?.driversTruncated !== undefined
|
||||
? { driversTruncated: driverMeta.driversTruncated }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function intentDisplayLabel(intent: NonNullable<PostureTarget['exposureIntent']>): string {
|
||||
switch (intent) {
|
||||
case 'lan': return 'LAN';
|
||||
case 'reverse-proxy': return 'reverse proxy';
|
||||
case 'same-node': return 'same-node';
|
||||
case 'internal': return 'internal';
|
||||
case 'public': return 'public';
|
||||
case 'temporary': return 'temporary';
|
||||
case 'unknown': return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Rank for multi-context pick: conflict, then unset, then set (non-conflict), then unavailable. */
|
||||
function evidenceRank(t: ExposureIntentSource): number {
|
||||
if (t.intentConflict) return 0;
|
||||
if (t.intentStatus === 'unset') return 1;
|
||||
if (t.intentStatus === 'set') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function isIntentionalSet(c: ExposureIntentSource): boolean {
|
||||
return (
|
||||
c.intentStatus === 'set'
|
||||
&& !c.intentConflict
|
||||
&& !!c.exposureIntent
|
||||
&& INTENTIONAL.has(c.exposureIntent)
|
||||
);
|
||||
}
|
||||
|
||||
function formatIntentEvidence(t: ExposureIntentSource): string | null {
|
||||
if (t.intentStatus === 'unavailable') return null;
|
||||
if (t.intentConflict) {
|
||||
const label = t.exposureIntent ? intentDisplayLabel(t.exposureIntent) : 'internal';
|
||||
return `Intent mismatch: ${label}`;
|
||||
}
|
||||
if (t.intentStatus === 'unset') return 'Intent: not classified';
|
||||
if (t.intentStatus === 'set' && t.exposureIntent) {
|
||||
return `Intent: ${intentDisplayLabel(t.exposureIntent)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer aggregate summary flags for the primary conclusion when present,
|
||||
* then fall back to the best ranked context line.
|
||||
*/
|
||||
function primaryLineFromContexts(
|
||||
contexts: ExposureIntentSource[],
|
||||
summary?: ImageExposureContextSummary,
|
||||
): string | null {
|
||||
if (contexts.length === 0) return null;
|
||||
|
||||
if (summary?.hasConflict) {
|
||||
const conflict = contexts.find((c) => c.intentConflict);
|
||||
const label = conflict?.exposureIntent
|
||||
? intentDisplayLabel(conflict.exposureIntent)
|
||||
: 'internal';
|
||||
return `Intent mismatch: ${label}`;
|
||||
}
|
||||
if (summary?.hasUnclassified) {
|
||||
return 'Intent: not classified';
|
||||
}
|
||||
|
||||
return [...contexts]
|
||||
.sort((a, b) => evidenceRank(a) - evidenceRank(b))
|
||||
.map(formatIntentEvidence)
|
||||
.find((line): line is string => line !== null) ?? null;
|
||||
}
|
||||
|
||||
function withExtras(primaryLine: string, totalContexts: number): string {
|
||||
const extras = totalContexts - 1;
|
||||
return extras > 0 ? `${primaryLine} (+${extras})` : primaryLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact per-row exposure intent line while targeting.
|
||||
* Accepts posture targets or standing ImageExposureContext rows.
|
||||
* Prefers conflict, then unset, then set; appends +N when more contexts exist.
|
||||
* Returns null when there is nothing to show (legacy imageRef-only targets, or unavailable-only).
|
||||
*/
|
||||
export function primaryExposureIntentEvidence(
|
||||
sources: ExposureIntentSource[] | undefined,
|
||||
imageRef?: string,
|
||||
): string | null {
|
||||
if (!sources || sources.length === 0) return null;
|
||||
|
||||
let list = sources;
|
||||
if (imageRef !== undefined) {
|
||||
const withRef = sources.filter((t): t is PostureTarget & ExposureIntentSource => 'imageRef' in t);
|
||||
list = withRef.length > 0
|
||||
? withRef.filter((t) => t.imageRef === imageRef)
|
||||
: sources;
|
||||
}
|
||||
if (list.length === 0) return null;
|
||||
|
||||
const primaryLine = primaryLineFromContexts(list);
|
||||
if (!primaryLine) return null;
|
||||
return withExtras(primaryLine, list.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standing Images evidence from a scan summary (no targeting required).
|
||||
* Mixed-version: publicly_exposed without contexts yields null (badge only).
|
||||
*/
|
||||
export function standingIntentEvidence(summary: ScanSummary): string | null {
|
||||
if (summary.publicly_exposed !== true) return null;
|
||||
const contexts = summary.exposure_contexts;
|
||||
if (!contexts || contexts.length === 0) return null;
|
||||
|
||||
const primaryLine = primaryLineFromContexts(contexts, summary.exposure_context_summary);
|
||||
if (!primaryLine) return null;
|
||||
|
||||
const total = summary.exposure_context_count ?? contexts.length;
|
||||
// When truncated, +N includes hidden contexts (count - displayed) plus other
|
||||
// displayed rows beyond the primary: total - 1.
|
||||
return withExtras(primaryLine, total);
|
||||
}
|
||||
|
||||
export type IntentionalBannerKind = 'absolute' | 'partial' | 'none';
|
||||
|
||||
export interface IntentionalBannerResult {
|
||||
kind: IntentionalBannerKind;
|
||||
unavailableCount: number;
|
||||
}
|
||||
|
||||
function intentionalKindFromContexts(
|
||||
contexts: ExposureIntentSource[] | undefined,
|
||||
opts: {
|
||||
truncated?: boolean;
|
||||
summary?: ImageExposureContextSummary;
|
||||
} = {},
|
||||
): IntentionalBannerResult {
|
||||
const { truncated = false, summary } = opts;
|
||||
if (truncated || !contexts || contexts.length === 0) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
|
||||
// Prefer pre-cap aggregates when present (standing summaries).
|
||||
if (summary) {
|
||||
if (summary.hasConflict || summary.hasUnclassified) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
if (summary.hasUnavailable) {
|
||||
const unavailableCount = contexts.filter((c) => c.intentStatus === 'unavailable').length;
|
||||
if (summary.allKnownIntentional && unavailableCount > 0) {
|
||||
return { kind: 'partial', unavailableCount };
|
||||
}
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
if (summary.allKnownIntentional && contexts.every(isIntentionalSet)) {
|
||||
return { kind: 'absolute', unavailableCount: 0 };
|
||||
}
|
||||
return { kind: 'none', 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 { kind: 'none', unavailableCount };
|
||||
}
|
||||
}
|
||||
|
||||
if (unavailableCount > 0 && sawAvailable) {
|
||||
return { kind: 'partial', unavailableCount };
|
||||
}
|
||||
if (unavailableCount === 0 && contexts.every(isIntentionalSet)) {
|
||||
return { kind: 'absolute', unavailableCount: 0 };
|
||||
}
|
||||
return { kind: 'none', unavailableCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute / partial intentional classification for targeting targets or a standing summary.
|
||||
* When truncated is true (overview attach capped or standing contexts truncated), never claim absolute/partial.
|
||||
*/
|
||||
export function intentionalBannerKind(
|
||||
input: PostureTarget[] | ScanSummary | undefined,
|
||||
opts?: { truncated?: boolean },
|
||||
): IntentionalBannerResult {
|
||||
if (!input) return { kind: 'none', unavailableCount: 0 };
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return intentionalKindFromContexts(input, { truncated: opts?.truncated === true });
|
||||
}
|
||||
|
||||
if (input.publicly_exposed !== true) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
return intentionalKindFromContexts(input.exposure_contexts, {
|
||||
truncated: opts?.truncated === true || input.exposure_contexts_truncated === true,
|
||||
summary: input.exposure_context_summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** Collect exposure contexts for networking navigation from a standing summary. */
|
||||
export function standingExposureContexts(summary: ScanSummary): ImageExposureContext[] {
|
||||
if (summary.publicly_exposed !== true) return [];
|
||||
return summary.exposure_contexts ?? [];
|
||||
}
|
||||
|
||||
/** Collect unique stack/service contexts from posture targets (banner networking nav). */
|
||||
export function allTargetingExposureContexts(
|
||||
targets: PostureTarget[],
|
||||
): ImageExposureContext[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ImageExposureContext[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.stackName || !t.serviceName) continue;
|
||||
const key = `${t.stackName}\0${t.serviceName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
stackName: t.stackName,
|
||||
serviceName: t.serviceName,
|
||||
exposureReason: t.exposureReason ?? null,
|
||||
exposureIntent: t.exposureIntent,
|
||||
intentStatus: t.intentStatus ?? 'unavailable',
|
||||
intentConflict: t.intentConflict,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collect stack/service contexts from posture targets for an image (networking nav). */
|
||||
export function targetingExposureContexts(
|
||||
targets: PostureTarget[] | undefined,
|
||||
imageRef: string,
|
||||
): ImageExposureContext[] {
|
||||
if (!targets) return [];
|
||||
return allTargetingExposureContexts(
|
||||
targets.filter((t) => t.imageRef === imageRef),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { PostureReasonKind } from '@/types/security';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
|
||||
/** The Images filter that best isolates the affected images for a posture reason.
|
||||
* Only fixable findings map to a data-backed filter; known-exploited and
|
||||
* public-exposure have no per-image flag in the summaries, so they open Images
|
||||
* unfiltered rather than mis-hiding the affected images. */
|
||||
/** The Images severity filter that best isolates the affected images when a
|
||||
* posture reason has no targets (older remote node). With targets present,
|
||||
* Images uses reason targeting instead. waiting_upstream /
|
||||
* update_check_uncertain open Images unfiltered via View findings when
|
||||
* targets are absent rather than inventing a severity dimension. */
|
||||
export function reasonImageFilter(kind: PostureReasonKind): ImageFilterValue | undefined {
|
||||
return kind === 'fixable_cve' ? 'FIXABLE' : undefined;
|
||||
}
|
||||
|
||||
/** Default Open-button label for a reason when actionLabel is omitted. */
|
||||
export function defaultReasonActionLabel(targetTab: string): string {
|
||||
if (targetTab === 'compose') return 'Open Compose risks';
|
||||
if (targetTab === 'suppressions') return 'Open Suppressions';
|
||||
if (targetTab === 'secrets') return 'Open Secrets';
|
||||
if (targetTab === 'history') return 'Open History';
|
||||
if (targetTab === 'scanner') return 'Open Scanner setup';
|
||||
return 'Open Images';
|
||||
}
|
||||
|
||||
@@ -18,15 +18,13 @@ export const SCANNER_DETECTIONS_NOTE =
|
||||
/**
|
||||
* Derives the Security masthead from action posture, not raw severity. Raw
|
||||
* Critical/High counts are scanner detections shown separately; they no longer
|
||||
* decide the headline. "Secure" means nothing is actionable right now, never a
|
||||
* claim that no vulnerabilities exist.
|
||||
* decide the headline alone.
|
||||
*
|
||||
* The backend computes the authoritative `posture` (one bucketing function), so
|
||||
* this prefers `overview.posture` when present. The local bootstrap below is the
|
||||
* fallback for an older remote node reached through the proxy that does not
|
||||
* report posture: "actionable" is approximated from the overview facts that
|
||||
* already exist (fixable findings, secrets, misconfigs); Unknown covers a
|
||||
* missing scanner or a node that has never completed a scan.
|
||||
* Prefer backend `overview.posture` (Secure requires cleared residual Crit/High
|
||||
* and review conditions; accepting residual risk stays Monitoring). The local
|
||||
* bootstrap below is only for older remotes that omit posture: actionable is
|
||||
* approximated from fixable/secrets/misconfigs; Unknown covers a missing
|
||||
* scanner or never-scanned node.
|
||||
*/
|
||||
export function deriveMasthead(
|
||||
overview: SecurityOverview | null,
|
||||
|
||||
@@ -124,6 +124,17 @@ export interface MisconfigAcknowledgement {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Triage decision states (mirrors the backend TriageStatus). */
|
||||
export type TriageStatus =
|
||||
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
|
||||
|
||||
/** OpenVEX-aligned justification codes (mirrors the backend TriageJustification). */
|
||||
export type TriageJustification =
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'component_not_present'
|
||||
| 'inline_mitigations_already_exist';
|
||||
|
||||
export interface VulnerabilityDetail {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
@@ -153,19 +164,11 @@ export interface VulnerabilityDetail {
|
||||
suppressed?: boolean;
|
||||
suppression_id?: number;
|
||||
suppression_reason?: string;
|
||||
/** Finding-scoped triage decision when joined from suppressions. */
|
||||
triage_status?: TriageStatus;
|
||||
triage_justification?: TriageJustification | null;
|
||||
}
|
||||
|
||||
/** Triage decision states (mirrors the backend TriageStatus). */
|
||||
export type TriageStatus =
|
||||
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
|
||||
|
||||
/** OpenVEX-aligned justification codes (mirrors the backend TriageJustification). */
|
||||
export type TriageJustification =
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'component_not_present'
|
||||
| 'inline_mitigations_already_exist';
|
||||
|
||||
export interface CveSuppression {
|
||||
id: number;
|
||||
cve_id: string;
|
||||
@@ -181,6 +184,24 @@ export interface CveSuppression {
|
||||
justification?: TriageJustification | null;
|
||||
}
|
||||
|
||||
/** Per stack/service exposure + Networking intent for a standing image summary. */
|
||||
export interface ImageExposureContext {
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
exposureReason: 'published-port' | 'host-network' | null;
|
||||
exposureIntent?: 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
|
||||
intentStatus: 'set' | 'unset' | 'unavailable';
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
/** Pre-cap aggregates for standing exposure conclusions (authoritative vs display slice). */
|
||||
export interface ImageExposureContextSummary {
|
||||
hasConflict: boolean;
|
||||
hasUnclassified: boolean;
|
||||
hasUnavailable: boolean;
|
||||
allKnownIntentional: boolean;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
@@ -195,6 +216,15 @@ export interface ScanSummary {
|
||||
fixable: number;
|
||||
secret_count: number;
|
||||
misconfig_count: number;
|
||||
/** Tri-state Compose exposure from cached stack descriptors (route enrichment). */
|
||||
publicly_exposed?: boolean | null;
|
||||
/** Capped display list of stack/service exposure contexts (when publicly exposed). */
|
||||
exposure_contexts?: ImageExposureContext[];
|
||||
/** Total contexts after dedupe, before display cap. */
|
||||
exposure_context_count?: number;
|
||||
exposure_contexts_truncated?: boolean;
|
||||
/** Aggregates over the full list before cap; prefer for banner/row conclusions. */
|
||||
exposure_context_summary?: ImageExposureContextSummary;
|
||||
}
|
||||
|
||||
export interface ScanPolicy {
|
||||
@@ -257,7 +287,10 @@ export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | '
|
||||
/** Kinds of posture reason the backend can report. */
|
||||
export type PostureReasonKind =
|
||||
| 'fixable_cve'
|
||||
| 'waiting_upstream'
|
||||
| 'update_check_uncertain'
|
||||
| 'known_exploited'
|
||||
| 'elevated_exploit_risk'
|
||||
| 'secret'
|
||||
| 'dangerous_compose'
|
||||
| 'public_exposure'
|
||||
@@ -265,6 +298,27 @@ export type PostureReasonKind =
|
||||
| 'failed_scan'
|
||||
| 'needs_review';
|
||||
|
||||
/** Bounded finding identities that drive a vulnerability-derived posture reason. */
|
||||
export interface PostureDriverFinding {
|
||||
vulnerabilityId: string;
|
||||
imageRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
stackName?: string;
|
||||
serviceName?: string;
|
||||
exposureReason?: 'published-port' | 'host-network' | null;
|
||||
exposureIntent?: 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
|
||||
intentStatus?: 'set' | 'unset' | 'unavailable';
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
/** One structured reason explaining why the security posture is what it is. */
|
||||
export interface PostureReason {
|
||||
kind: PostureReasonKind;
|
||||
@@ -273,6 +327,22 @@ export interface PostureReason {
|
||||
label: string;
|
||||
description: string;
|
||||
targetTab: SecurityTab;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Highest-priority action for the masthead CTA. */
|
||||
@@ -282,6 +352,12 @@ export interface PostureAction {
|
||||
/** The reason kind behind 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;
|
||||
}
|
||||
|
||||
/** Node-scoped security posture rollup for the Security page Overview. */
|
||||
@@ -318,7 +394,7 @@ export interface SecurityOverview {
|
||||
needsReview?: number;
|
||||
accepted?: number;
|
||||
notAffected?: number;
|
||||
/** Total actionable items, for the "N actions" affordance. */
|
||||
/** Legacy mixed-unit sum of blocker counts. Prefer posture / reasons. */
|
||||
actionable?: number;
|
||||
posture?: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
@@ -328,6 +404,8 @@ export interface SecurityOverview {
|
||||
postureReasons?: PostureReason[];
|
||||
/** Highest-priority action for the masthead CTA, or null when no blockers. */
|
||||
primaryAction?: PostureAction | null;
|
||||
/** True when image-update checks are disabled (gates Check again on uncertain rows). */
|
||||
updateChecksDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */
|
||||
|
||||
Reference in New Issue
Block a user