mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation Bring vulnerability scanning, scan history, suppressions, Compose risks, secrets, policy packs, and scanner setup into one node-scoped Security command center instead of scattering them across Resources and Settings. - New top-level Security view with Overview, Images, Compose risks, Secrets, Policies, Suppressions, History, and Scanner setup tabs (status masthead + signal rail; controlled tabs with deep-link support). - Backend: GET /security/overview rollup and GET /security/policy-packs static catalog (auth-only, Community). DatabaseService gains an uncapped scan-status count and a node-eligible block-policy count, and getImageScanSummaries now projects secret and misconfig counts. - Reuse existing surfaces: the scan-history sheet, the control-governed suppression and acknowledgement panels, and the scan-detail sheet (now with an initial-tab prop so it opens on the matching finding type). - Extract a shared SeverityBadge (from Resources) and a TrivyManager (from Settings) so both surfaces render identical controls. - Resources "Scan history" now links into the Security page History tab. - Docs for the new Security surface and tests for the new endpoints, helpers, nav wiring, and tabs. * refactor: consolidate scanner and policy management onto the Security page Remove the Settings "Vulnerability Scanning" section now that the Security page covers the same ground, with every option preserved: - Scanner install / update / uninstall / auto-update live on the Scanner setup tab (TrivyManager). - Scan policies, the honor-suppressions toggle, and the replica managed-by-control / demote controls move into a new ScanPolicyManager on the Policies tab (paid; Community sees only the policy-pack catalog). - CVE suppressions and acknowledgements remain on the Suppressions tab. Wiring removed: the registry section and the now-empty Security settings group, the SectionId, the SettingsSectionContent case and the isPaid prop it was the sole consumer of, and SecuritySection itself. The dashboard configuration-status "Vulnerability scanning" row now navigates to the Security page Policies tab. Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept to the relevant Security page tabs. * fix: harden Security page scanner refresh, policy-load errors, and secret-only badges Address independent-review findings on the Security page: - Scanner setup now refreshes Trivy state when the active node changes, so the displayed scanner status matches the node TrivyManager's actions target (both follow x-node-id). Previously, switching nodes on the tab left stale state. - ScanPolicyManager surfaces an explicit error state on a failed policy fetch instead of falling through to a false "No scan policies configured". - The shared SeverityBadge and the Images findings column no longer label a scan "clean" when it has secrets or misconfigurations but no CVE severity (highest_severity is derived from vulnerabilities only); they show a "Findings" state and the secret/misconfig counts instead. - The Overview enforcement note points to the Policies tab, not the removed Settings section. - The History tab auto-opens the scan-history sheet only on a deep-link (mount with the History tab active), not on every manual tab selection. Adds tests for the badge secret/misconfig state and the policy-load error state.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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_scans').run();
|
||||
raw.prepare('DELETE FROM scan_policies').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, 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, 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, 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('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/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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user