mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
89a13f51e9
The Security overview and exploit-intel surfaces picked the latest scan per image without restricting to scans that ran the vulnerability scanner, and counted known-exploited (KEV) findings only among Critical/High. Two effects: - A newer secret-only node scan became the latest scan for an image and clobbered its Critical/High/fixable/KEV posture to zero, which could read a false Secure state. - A Medium or Low severity KEV that the pre-deploy gate blocks on produced zero overview and exploit-intel rows, so the page disagreed with the gate. Posture queries now select the latest vulnerability-bearing scan per image, the image summary sources its vulnerability counts from that scan via a LEFT JOIN while still counting secret and misconfiguration findings from the latest scan overall, and knownExploited is counted from a dedicated any-severity KEV query that mirrors the gate.
546 lines
28 KiB
TypeScript
546 lines
28 KiB
TypeScript
/**
|
|
* GET /api/security/overview -> node-scoped posture rollup (Community, auth-only)
|
|
* GET /api/security/policy-packs -> static catalog (Community, auth-only, identical per tier)
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import bcrypt from 'bcrypt';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let adminCookie: string;
|
|
let viewerCookie: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
|
let TrivyService: typeof import('../services/TrivyService').default;
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ LicenseService } = await import('../services/LicenseService'));
|
|
TrivyService = (await import('../services/TrivyService')).default;
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
// Deterministic scanner readout for the overview's scanner block.
|
|
const svc = TrivyService.getInstance();
|
|
vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true);
|
|
vi.spyOn(svc, 'getVersion').mockReturnValue('0.52.0');
|
|
vi.spyOn(svc, 'getSource').mockReturnValue('managed');
|
|
({ app } = await import('../index'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
|
|
const viewerHash = await bcrypt.hash('ovviewer1', 1);
|
|
DatabaseService.getInstance().addUser({ username: 'ov-viewer', password_hash: viewerHash, role: 'viewer' });
|
|
const res = await request(app).post('/api/auth/login').send({ username: 'ov-viewer', password: 'ovviewer1' });
|
|
const cookies = res.headers['set-cookie'] as string | string[];
|
|
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
function db() {
|
|
return DatabaseService.getInstance();
|
|
}
|
|
|
|
function seedScan(o: {
|
|
node_id?: number;
|
|
image_ref: string;
|
|
scanned_at: number;
|
|
status?: 'completed' | 'failed';
|
|
critical?: number;
|
|
high?: number;
|
|
fixable?: number;
|
|
secret?: number;
|
|
misconfig?: number;
|
|
}): void {
|
|
db().createVulnerabilityScan({
|
|
node_id: o.node_id ?? 1,
|
|
image_ref: o.image_ref,
|
|
image_digest: `sha256:${o.image_ref}-${Math.random().toString(16).slice(2)}`,
|
|
scanned_at: o.scanned_at,
|
|
total_vulnerabilities: (o.critical ?? 0) + (o.high ?? 0),
|
|
critical_count: o.critical ?? 0,
|
|
high_count: o.high ?? 0,
|
|
medium_count: 0,
|
|
low_count: 0,
|
|
unknown_count: 0,
|
|
fixable_count: o.fixable ?? 0,
|
|
secret_count: o.secret ?? 0,
|
|
misconfig_count: o.misconfig ?? 0,
|
|
scanners_used: 'vuln',
|
|
highest_severity: (o.critical ?? 0) > 0 ? 'CRITICAL' : null,
|
|
os_info: null,
|
|
trivy_version: null,
|
|
scan_duration_ms: null,
|
|
triggered_by: 'manual',
|
|
status: o.status ?? 'completed',
|
|
error: o.status === 'failed' ? 'boom' : null,
|
|
stack_context: o.image_ref.startsWith('stack:') ? o.image_ref.slice(6) : null,
|
|
});
|
|
}
|
|
|
|
function resetSecurity(): void {
|
|
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
|
raw.prepare('DELETE FROM vulnerability_details').run();
|
|
raw.prepare('DELETE FROM vulnerability_scans').run();
|
|
raw.prepare('DELETE FROM scan_policies').run();
|
|
raw.prepare('DELETE FROM cve_suppressions').run();
|
|
raw.prepare('DELETE FROM misconfig_acknowledgements').run();
|
|
raw.prepare('DELETE FROM cve_intel').run();
|
|
}
|
|
|
|
describe('GET /api/security/overview', () => {
|
|
beforeEach(() => {
|
|
resetSecurity();
|
|
db().updateGlobalSetting('deploy_block_honor_suppressions', '1');
|
|
});
|
|
|
|
it('aggregates node-scoped counts with the documented shape', async () => {
|
|
const now = Date.now();
|
|
seedScan({ image_ref: 'imgA:1', scanned_at: now - 1000, critical: 2, high: 1, fixable: 3, secret: 1 });
|
|
seedScan({ image_ref: 'imgB:1', scanned_at: now - 8 * DAY }); // stale
|
|
seedScan({ image_ref: 'stack:web', scanned_at: now - 2000, misconfig: 2 });
|
|
// Failed scans (same image) beyond a single row prove the uncapped count.
|
|
for (let i = 0; i < 4; i++) seedScan({ image_ref: 'imgA:1', scanned_at: now, status: 'failed' });
|
|
// Other node's data must be excluded.
|
|
seedScan({ node_id: 2, image_ref: 'other:1', scanned_at: now, critical: 99 });
|
|
|
|
// One fleet-wide and one this-node block policy count; an other-node one does not.
|
|
db().createScanPolicy({ name: 'fw', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 0 });
|
|
db().createScanPolicy({ name: 'n1', node_id: 1, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 0 });
|
|
db().createScanPolicy({ name: 'n2', node_id: 2, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 0 });
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
scannedImages: 2, // imgA + imgB, stack:web excluded
|
|
critical: 2, // node-2's 99 excluded
|
|
high: 1,
|
|
fixable: 3,
|
|
secrets: 1,
|
|
misconfigs: 2,
|
|
staleScans: 1, // imgB only
|
|
failedScans: 4, // uncapped
|
|
});
|
|
expect(res.body.lastSuccessfulScanAt).toBeGreaterThan(0);
|
|
expect(res.body.scanner).toMatchObject({ available: true, source: 'managed', version: '0.52.0' });
|
|
expect(res.body.deployEnforcement).toMatchObject({
|
|
honorSuppressionsOnDeploy: true,
|
|
eligibleBlockPolicies: 2,
|
|
});
|
|
});
|
|
|
|
it('derives suppression- and ack-aware posture facts from detail rows', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1,
|
|
image_ref: 'app:1',
|
|
image_digest: `sha256:app-${Math.random().toString(16).slice(2)}`,
|
|
scanned_at: now,
|
|
total_vulnerabilities: 3,
|
|
critical_count: 2,
|
|
high_count: 1,
|
|
medium_count: 0,
|
|
low_count: 0,
|
|
unknown_count: 0,
|
|
fixable_count: 2,
|
|
secret_count: 0,
|
|
misconfig_count: 2,
|
|
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 detail = (vulnerability_id: string, severity: 'CRITICAL' | 'HIGH', fixed_version: string | null) => ({
|
|
vulnerability_id, pkg_name: `pkg-${vulnerability_id}`, installed_version: '1', fixed_version,
|
|
severity, title: null, description: null, primary_url: null,
|
|
});
|
|
db().insertVulnerabilityDetails(scanId, [
|
|
detail('CVE-2024-0001', 'CRITICAL', '2'), // fixable, counts
|
|
detail('CVE-2024-0002', 'HIGH', null), // unfixable, does not count
|
|
detail('CVE-2024-0003', 'CRITICAL', '9'), // fixable but suppressed -> accepted, not fixable
|
|
]);
|
|
db().createCveSuppression({
|
|
cve_id: 'CVE-2024-0003', pkg_name: null, image_pattern: null, reason: 'accepted risk',
|
|
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0,
|
|
});
|
|
db().insertMisconfigFindings(scanId, [
|
|
{ rule_id: 'DS001', check_id: null, severity: 'HIGH', title: null, message: null, resolution: null, target: 'app', primary_url: null },
|
|
{ rule_id: 'DS002', check_id: null, severity: 'CRITICAL', title: null, message: null, resolution: null, target: 'app', primary_url: null },
|
|
]);
|
|
db().createMisconfigAcknowledgement({
|
|
rule_id: 'DS001', stack_pattern: null, reason: 'acknowledged',
|
|
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0,
|
|
});
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
rawCritical: 2,
|
|
rawHigh: 1,
|
|
fixableCriticalHigh: 1, // 0001 only (0003 suppressed, 0002 unfixable)
|
|
accepted: 1, // 0003 suppressed
|
|
dangerousCompose: 1, // DS002 (DS001 acknowledged)
|
|
knownExploited: 0,
|
|
publiclyExposed: 0,
|
|
needsReview: 0,
|
|
notAffected: 0,
|
|
posture: 'Action needed',
|
|
posturePartial: false,
|
|
});
|
|
});
|
|
|
|
it('separates not_affected and needs_review triage facts in the overview', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'triage:1', image_digest: 'sha256:triage', scanned_at: now,
|
|
total_vulnerabilities: 2, critical_count: 2, 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-0010', pkg_name: 'a', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
|
|
{ vulnerability_id: 'CVE-2024-0011', pkg_name: 'b', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
|
|
]);
|
|
db().createCveSuppression({ cve_id: 'CVE-2024-0010', pkg_name: null, image_pattern: null, reason: 'not affected', created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'not_affected' });
|
|
db().createCveSuppression({ cve_id: 'CVE-2024-0011', pkg_name: null, image_pattern: null, reason: 'reviewing', created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'needs_review' });
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ notAffected: 1, needsReview: 1, accepted: 0, fixableCriticalHigh: 0, posture: 'Monitoring' });
|
|
});
|
|
|
|
it('escalates an unfixable known-exploited (KEV) finding to Action needed', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'kev:1', image_digest: 'sha256: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-9999', pkg_name: 'libkev', installed_version: '1', fixed_version: null,
|
|
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
|
}]);
|
|
// No fix available, but the CVE is known-exploited: KEV overrides "no fix".
|
|
db().replaceKev([{ cve_id: 'CVE-2024-9999', date_added: '2024-01-01' }], now);
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ knownExploited: 1, fixableCriticalHigh: 0, posture: 'Action needed' });
|
|
});
|
|
|
|
it('preserves vulnerability posture when a newer secret-only scan exists for the same image', async () => {
|
|
const now = Date.now();
|
|
// Vulnerability scan: one unfixable Critical that is known-exploited.
|
|
const vulnScan = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app-vuln', scanned_at: now - 1000,
|
|
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(vulnScan, [{
|
|
vulnerability_id: 'CVE-2024-5001', pkg_name: 'libkev', installed_version: '1', fixed_version: null,
|
|
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
|
}]);
|
|
db().replaceKev([{ cve_id: 'CVE-2024-5001', date_added: '2024-01-01' }], now);
|
|
// Newer secret-only node scan for the SAME image: no vuln details, carries a secret.
|
|
db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app-secret', 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: 1, misconfig_count: 0, scanners_used: 'secret',
|
|
highest_severity: null, os_info: null, trivy_version: null, scan_duration_ms: null,
|
|
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
|
});
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
// The secret-only scan must not erase the vulnerability posture, and its
|
|
// secret finding is still counted.
|
|
expect(res.body).toMatchObject({
|
|
critical: 1,
|
|
knownExploited: 1,
|
|
secrets: 1,
|
|
posture: 'Action needed',
|
|
});
|
|
});
|
|
|
|
it('counts a Low/Medium known-exploited finding in knownExploited and posture (matches the deploy gate)', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'lowkev:1', image_digest: 'sha256:lowkev', scanned_at: now,
|
|
total_vulnerabilities: 1, critical_count: 0, high_count: 0, medium_count: 0, low_count: 1,
|
|
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
|
highest_severity: 'LOW', 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-5002', pkg_name: 'liblow', installed_version: '1', fixed_version: null,
|
|
severity: 'LOW', title: null, description: null, primary_url: null,
|
|
}]);
|
|
// The deploy gate blocks a KEV at any severity; the overview must agree.
|
|
db().replaceKev([{ cve_id: 'CVE-2024-5002', date_added: '2024-01-01' }], now);
|
|
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ knownExploited: 1, posture: 'Action needed' });
|
|
});
|
|
|
|
it('excludes a suppressed (accepted) KEV from knownExploited', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'suppkev:1', image_digest: 'sha256:suppkev', scanned_at: now,
|
|
total_vulnerabilities: 2, critical_count: 0, high_count: 0, medium_count: 2, low_count: 0,
|
|
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
|
highest_severity: 'MEDIUM', 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-KEVA', pkg_name: 'a', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null },
|
|
{ vulnerability_id: 'CVE-2024-KEVB', pkg_name: 'b', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null },
|
|
]);
|
|
db().replaceKev([{ cve_id: 'CVE-2024-KEVA', date_added: '2024-01-01' }, { cve_id: 'CVE-2024-KEVB', date_added: '2024-01-01' }], now);
|
|
// KEVA is accepted (dismissed); only the live KEVB remains actionable.
|
|
db().createCveSuppression({ cve_id: 'CVE-2024-KEVA', pkg_name: null, image_pattern: null, reason: 'accepted', 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.knownExploited).toBe(1);
|
|
});
|
|
|
|
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(),
|
|
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,
|
|
});
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.posture).toBe('Secure');
|
|
});
|
|
|
|
it('is reachable by a Community viewer (read-only, auth-only)', async () => {
|
|
const res = await request(app).get('/api/security/overview').set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('requires authentication', async () => {
|
|
const res = await request(app).get('/api/security/overview');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/overview/trend', () => {
|
|
beforeEach(() => resetSecurity());
|
|
|
|
const dayStart = (daysAgo: number): number => {
|
|
const d = new Date(Date.now() - daysAgo * DAY);
|
|
d.setUTCHours(0, 0, 0, 0);
|
|
return d.getTime();
|
|
};
|
|
|
|
it('returns ascending daily critical/high points, node-scoped and completed only', async () => {
|
|
const d1 = dayStart(3);
|
|
const d2 = dayStart(2);
|
|
seedScan({ image_ref: 'a:1', scanned_at: d1 + 3_600_000, critical: 4, high: 2 });
|
|
seedScan({ image_ref: 'a:1', scanned_at: d2 + 3_600_000, critical: 1, high: 5 });
|
|
seedScan({ node_id: 2, image_ref: 'x:1', scanned_at: d2 + 3_600_000, critical: 9, high: 9 }); // other node
|
|
seedScan({ image_ref: 'f:1', scanned_at: d2 + 3_600_000, critical: 7, high: 7, status: 'failed' }); // failed
|
|
|
|
const res = await request(app).get('/api/security/overview/trend').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
expect(res.body).toHaveLength(2);
|
|
expect(res.body[0]).toMatchObject({ critical: 4, high: 2 });
|
|
expect(res.body[1]).toMatchObject({ critical: 1, high: 5 });
|
|
expect(res.body[0].date < res.body[1].date).toBe(true);
|
|
});
|
|
|
|
it('requires authentication', async () => {
|
|
const res = await request(app).get('/api/security/overview/trend');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/policy-packs', () => {
|
|
it('returns the 5 default packs with fully-formed rules (auth-only)', async () => {
|
|
const res = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
expect(res.body).toHaveLength(5);
|
|
for (const pack of res.body) {
|
|
expect(pack).toMatchObject({
|
|
id: expect.any(String),
|
|
name: expect.any(String),
|
|
tagline: expect.any(String),
|
|
tierCopy: expect.any(String),
|
|
});
|
|
expect(Array.isArray(pack.rules)).toBe(true);
|
|
expect(pack.rules.length).toBeGreaterThan(0);
|
|
for (const rule of pack.rules) {
|
|
expect(rule).toMatchObject({
|
|
id: expect.any(String),
|
|
name: expect.any(String),
|
|
severity: expect.stringMatching(/^(CRITICAL|HIGH|MEDIUM|LOW)$/),
|
|
whatItChecks: expect.any(String),
|
|
why: expect.any(String),
|
|
howToFix: expect.any(String),
|
|
enforcement: expect.stringMatching(/^(warning|enforceable)$/),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it('returns 401 unauthenticated', async () => {
|
|
const res = await request(app).get('/api/security/policy-packs');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns an identical catalog regardless of tier', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
const community = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
const paid = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
expect(paid.body).toEqual(community.body);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/scans/:scanId/vulnerabilities', () => {
|
|
beforeEach(() => resetSecurity());
|
|
|
|
it('attaches read-time exploit intel (KEV/EPSS) to each finding', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'vex:1', image_digest: 'sha256:vex', 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-7777', pkg_name: 'p', installed_version: '1', fixed_version: null,
|
|
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
|
}]);
|
|
db().replaceKev([{ cve_id: 'CVE-2024-7777', date_added: '2024-02-02' }], now);
|
|
db().upsertEpss([{ cve_id: 'CVE-2024-7777', epss_score: 0.42, epss_percentile: 0.95 }], now);
|
|
|
|
const res = await request(app).get(`/api/security/scans/${scanId}/vulnerabilities`).set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
const item = (res.body.items as Array<{ vulnerability_id: string; kev: boolean; epss_score: number }>)
|
|
.find((i) => i.vulnerability_id === 'CVE-2024-7777');
|
|
expect(item).toMatchObject({ kev: true, epss_score: 0.42, epss_percentile: 0.95 });
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/vex/export (Admiral)', () => {
|
|
beforeEach(() => {
|
|
resetSecurity();
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
});
|
|
afterAll(() => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
});
|
|
|
|
it('is gated to Admiral: 403 for Community', async () => {
|
|
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('exports an OpenVEX document from triage decisions for Admiral', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
db().createCveSuppression({
|
|
cve_id: 'CVE-2024-2222', pkg_name: null, image_pattern: 'nginx*', reason: 'not present in build',
|
|
created_by: 'admin', created_at: Date.now(), expires_at: null, replicated_from_control: 0,
|
|
status: 'not_affected', justification: 'component_not_present',
|
|
});
|
|
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body['@context']).toContain('openvex');
|
|
const stmt = (res.body.statements as Array<{ vulnerability: { name: string }; status: string; justification?: string; products: string[] }>)
|
|
.find((s) => s.vulnerability.name === 'CVE-2024-2222');
|
|
expect(stmt).toMatchObject({ status: 'not_affected', justification: 'component_not_present', products: ['nginx*'] });
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/overview/exploit-intel', () => {
|
|
beforeEach(() => resetSecurity());
|
|
|
|
it('returns actionable Crit/High findings with KEV/EPSS joined and dismissed excluded', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app', scanned_at: now,
|
|
total_vulnerabilities: 3, critical_count: 2, high_count: 1, medium_count: 0, low_count: 0,
|
|
unknown_count: 0, fixable_count: 2, 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 d = (id: string, severity: 'CRITICAL' | 'HIGH', cvss: number | null, fixed: string | null) => ({
|
|
vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: fixed,
|
|
severity, title: null, description: null, primary_url: null, cvss_score: cvss,
|
|
});
|
|
db().insertVulnerabilityDetails(scanId, [
|
|
d('CVE-2024-AAAA', 'CRITICAL', 9.8, '2'), // actionable, has KEV + EPSS
|
|
d('CVE-2024-BBBB', 'HIGH', 7.2, null), // actionable, no intel yet
|
|
d('CVE-2024-CCCC', 'CRITICAL', 8.1, '3'), // dismissed -> excluded
|
|
]);
|
|
db().replaceKev([{ cve_id: 'CVE-2024-AAAA', date_added: '2024-01-01' }], now);
|
|
db().upsertEpss([{ cve_id: 'CVE-2024-AAAA', epss_score: 0.6, epss_percentile: 0.97 }], now);
|
|
db().createCveSuppression({
|
|
cve_id: 'CVE-2024-CCCC', pkg_name: null, image_pattern: null, reason: 'accepted',
|
|
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted',
|
|
});
|
|
|
|
const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
const items = res.body.items as Array<{ vulnerability_id: string; cvss_score: number | null; epss_score: number | null; kev: boolean; severity: string; scan_id: number }>;
|
|
const ids = items.map((i) => i.vulnerability_id);
|
|
expect(ids).toContain('CVE-2024-AAAA');
|
|
expect(ids).toContain('CVE-2024-BBBB');
|
|
expect(ids).not.toContain('CVE-2024-CCCC'); // dismissed triage decision
|
|
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-AAAA')).toMatchObject({ cvss_score: 9.8, epss_score: 0.6, kev: true, severity: 'CRITICAL', scan_id: scanId });
|
|
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-BBBB')).toMatchObject({ cvss_score: 7.2, epss_score: null, kev: false });
|
|
expect(res.body.truncated).toBe(false);
|
|
});
|
|
|
|
it('includes a Medium/Low known-exploited finding alongside Critical/High', async () => {
|
|
const now = Date.now();
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: 'mix:1', image_digest: 'sha256:mix', scanned_at: now,
|
|
total_vulnerabilities: 2, critical_count: 0, high_count: 0, medium_count: 1, low_count: 1,
|
|
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
|
highest_severity: 'MEDIUM', 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-MEDK', pkg_name: 'm', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null, cvss_score: 5.5 },
|
|
{ vulnerability_id: 'CVE-2024-LOWN', pkg_name: 'l', installed_version: '1', fixed_version: null, severity: 'LOW', title: null, description: null, primary_url: null, cvss_score: 3.1 },
|
|
]);
|
|
// Only the Medium finding is known-exploited; the Low non-KEV stays out.
|
|
db().replaceKev([{ cve_id: 'CVE-2024-MEDK', date_added: '2024-01-01' }], now);
|
|
|
|
const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
const ids = (res.body.items as Array<{ vulnerability_id: string }>).map((i) => i.vulnerability_id);
|
|
expect(ids).toContain('CVE-2024-MEDK');
|
|
expect(ids).not.toContain('CVE-2024-LOWN');
|
|
});
|
|
|
|
it('requires authentication', async () => {
|
|
const res = await request(app).get('/api/security/overview/exploit-intel');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|