mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +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,118 @@
|
||||
/**
|
||||
* Unit coverage for the two DatabaseService helpers added for the Security
|
||||
* overview:
|
||||
* - countScansByStatus: an UNCAPPED count (getVulnerabilityScans applies a
|
||||
* per-image history cap that would undercount failed scans).
|
||||
* - countEligibleBlockPolicies: counts enabled block-on-deploy policies that
|
||||
* apply to a node (fleet-wide or this node), built on getScanPoliciesForUi
|
||||
* so a replica never counts a sibling-identity policy.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { ScanPolicy } from '../services/DatabaseService';
|
||||
|
||||
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 vulnerability_scans').run();
|
||||
raw.prepare('DELETE FROM scan_policies').run();
|
||||
}
|
||||
|
||||
function seedFailed(imageRef: string): void {
|
||||
db().createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
image_ref: imageRef,
|
||||
image_digest: `sha256:${imageRef}-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: 1,
|
||||
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: 'failed',
|
||||
error: 'boom',
|
||||
stack_context: null,
|
||||
});
|
||||
}
|
||||
|
||||
function seedPolicy(overrides: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>): void {
|
||||
db().createScanPolicy({
|
||||
name: overrides.name ?? 'p',
|
||||
node_id: overrides.node_id ?? null,
|
||||
node_identity: overrides.node_identity ?? '',
|
||||
stack_pattern: overrides.stack_pattern ?? null,
|
||||
max_severity: overrides.max_severity ?? 'CRITICAL',
|
||||
block_on_deploy: overrides.block_on_deploy ?? 1,
|
||||
enabled: overrides.enabled ?? 1,
|
||||
replicated_from_control: overrides.replicated_from_control ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => reset());
|
||||
|
||||
describe('countScansByStatus', () => {
|
||||
it('counts failed scans uncapped, even beyond the per-image history cap', () => {
|
||||
// The grouped history view caps rows per image_ref (default 50). All 55 of
|
||||
// these are the same image, so a capped path would undercount.
|
||||
for (let i = 0; i < 55; i++) seedFailed('same-image:1');
|
||||
expect(db().countScansByStatus(1, 'failed')).toBe(55);
|
||||
});
|
||||
|
||||
it('is node-scoped', () => {
|
||||
seedFailed('a:1');
|
||||
db().createVulnerabilityScan({
|
||||
node_id: 2, image_ref: 'b:1', image_digest: 'sha256:b', scanned_at: 1,
|
||||
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: 'failed', error: 'x', stack_context: null,
|
||||
});
|
||||
expect(db().countScansByStatus(1, 'failed')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countEligibleBlockPolicies (control)', () => {
|
||||
it('counts fleet-wide and this-node block policies, excludes other nodes / disabled / non-blocking', () => {
|
||||
seedPolicy({ name: 'fleet-wide', node_id: null }); // counted
|
||||
seedPolicy({ name: 'this-node', node_id: 1 }); // counted
|
||||
seedPolicy({ name: 'other-node', node_id: 2 }); // excluded (different node)
|
||||
seedPolicy({ name: 'disabled', node_id: 1, enabled: 0 }); // excluded (disabled)
|
||||
seedPolicy({ name: 'no-block', node_id: 1, block_on_deploy: 0 }); // excluded (not blocking)
|
||||
|
||||
expect(db().countEligibleBlockPolicies(1, 'control', '')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countEligibleBlockPolicies (replica)', () => {
|
||||
it('filters a replicated policy scoped to a sibling identity, keeps fleet-wide', () => {
|
||||
// Fleet-wide replicated row (empty identity) applies on every replica.
|
||||
seedPolicy({ name: 'fleet-wide', node_id: null, replicated_from_control: 1, node_identity: '' });
|
||||
// Sibling-scoped replicated row must not be counted on this replica.
|
||||
seedPolicy({ name: 'sibling', node_id: null, replicated_from_control: 1, node_identity: 'sibling-id' });
|
||||
|
||||
expect(db().countEligibleBlockPolicies(1, 'replica', 'self-id')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { isDebugEnabled } from '../utils/debug';
|
||||
import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
||||
import { validateStackPatternForRedos } from './fleet';
|
||||
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
// Trivy emits misconfig rule ids in two shapes that Sencho persists verbatim:
|
||||
@@ -113,6 +114,28 @@ function shapeScanForResponse(scan: VulnerabilityScan): Omit<VulnerabilityScan,
|
||||
return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) };
|
||||
}
|
||||
|
||||
// A completed scan whose latest run is older than this is considered "stale" in
|
||||
// the Security overview. Named so the route and its tests share one value.
|
||||
export const STALE_SCAN_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
// Shape of the /overview response. Mirrors the frontend `SecurityOverview` type
|
||||
// (frontend/src/types/security.ts); annotating the response below makes a
|
||||
// renamed or dropped field a compile error here instead of an undefined read in
|
||||
// the UI. Keep the two in sync.
|
||||
interface SecurityOverviewResponse {
|
||||
scannedImages: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
fixable: number;
|
||||
secrets: number;
|
||||
misconfigs: number;
|
||||
staleScans: number;
|
||||
failedScans: number;
|
||||
lastSuccessfulScanAt: number | null;
|
||||
scanner: { available: boolean; version: string | null; source: 'managed' | 'host' | 'none'; autoUpdate: boolean };
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: boolean; eligibleBlockPolicies: number };
|
||||
}
|
||||
|
||||
export const securityRouter = Router();
|
||||
|
||||
securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Response) => {
|
||||
@@ -437,6 +460,83 @@ securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Respo
|
||||
}
|
||||
});
|
||||
|
||||
// Node-scoped security posture rollup for the Security page Overview. Read-only,
|
||||
// auth-only (Community). Counts derive from the latest-completed-scan-per-image
|
||||
// summaries plus two precise helpers; the deploy-enforcement block is this
|
||||
// node's read-only posture, not policy management.
|
||||
securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const summaries = Object.values(db.getImageScanSummaries(req.nodeId));
|
||||
const settings = db.getGlobalSettings();
|
||||
const svc = TrivyService.getInstance();
|
||||
|
||||
const now = Date.now();
|
||||
let scannedImages = 0;
|
||||
let critical = 0;
|
||||
let high = 0;
|
||||
let fixable = 0;
|
||||
let secrets = 0;
|
||||
let misconfigs = 0;
|
||||
let staleScans = 0;
|
||||
let lastSuccessfulScanAt: number | null = null;
|
||||
|
||||
for (const s of summaries) {
|
||||
// Severity, secret, and misconfig totals are summed across every summary
|
||||
// (real images and stack/config scans alike). Only scannedImages excludes
|
||||
// the stack/config rows (stored under a "stack:" image_ref), since those
|
||||
// are stacks, not images.
|
||||
if (!s.image_ref.startsWith('stack:')) scannedImages += 1;
|
||||
critical += s.critical;
|
||||
high += s.high;
|
||||
fixable += s.fixable;
|
||||
secrets += s.secret_count;
|
||||
misconfigs += s.misconfig_count;
|
||||
if (now - s.scanned_at > STALE_SCAN_THRESHOLD_MS) staleScans += 1;
|
||||
if (lastSuccessfulScanAt === null || s.scanned_at > lastSuccessfulScanAt) {
|
||||
lastSuccessfulScanAt = s.scanned_at;
|
||||
}
|
||||
}
|
||||
|
||||
const overview: SecurityOverviewResponse = {
|
||||
scannedImages,
|
||||
critical,
|
||||
high,
|
||||
fixable,
|
||||
secrets,
|
||||
misconfigs,
|
||||
staleScans,
|
||||
failedScans: db.countScansByStatus(req.nodeId, 'failed'),
|
||||
lastSuccessfulScanAt,
|
||||
scanner: {
|
||||
available: svc.isTrivyAvailable(),
|
||||
version: svc.getVersion(),
|
||||
source: svc.getSource(),
|
||||
autoUpdate: settings.trivy_auto_update === '1',
|
||||
},
|
||||
deployEnforcement: {
|
||||
honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1',
|
||||
eligibleBlockPolicies: db.countEligibleBlockPolicies(
|
||||
req.nodeId,
|
||||
FleetSyncService.getRole(),
|
||||
FleetSyncService.getSelfIdentity(),
|
||||
),
|
||||
},
|
||||
};
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to build overview:', error);
|
||||
res.status(500).json({ error: 'Failed to build security overview' });
|
||||
}
|
||||
});
|
||||
|
||||
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
|
||||
// enforcement. The frontend fetches this with localOnly so the global catalog
|
||||
// is available regardless of which node is active.
|
||||
securityRouter.get('/policy-packs', authMiddleware, (_req: Request, res: Response): void => {
|
||||
res.json(DEFAULT_POLICY_PACKS);
|
||||
});
|
||||
|
||||
securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
|
||||
@@ -703,6 +703,8 @@ export interface ScanSummary {
|
||||
low: number;
|
||||
unknown: number;
|
||||
fixable: number;
|
||||
secret_count: number;
|
||||
misconfig_count: number;
|
||||
scanned_at: number;
|
||||
scan_id: number;
|
||||
}
|
||||
@@ -4451,7 +4453,7 @@ export class DatabaseService {
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
|
||||
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
|
||||
vs.unknown_count, vs.fixable_count, vs.scanned_at
|
||||
vs.unknown_count, vs.fixable_count, vs.secret_count, vs.misconfig_count, vs.scanned_at
|
||||
FROM vulnerability_scans vs
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
@@ -4472,6 +4474,8 @@ export class DatabaseService {
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
secret_count: number;
|
||||
misconfig_count: number;
|
||||
scanned_at: number;
|
||||
}>;
|
||||
const out: Record<string, ScanSummary> = {};
|
||||
@@ -4486,6 +4490,8 @@ export class DatabaseService {
|
||||
low: r.low_count,
|
||||
unknown: r.unknown_count,
|
||||
fixable: r.fixable_count,
|
||||
secret_count: r.secret_count,
|
||||
misconfig_count: r.misconfig_count,
|
||||
scanned_at: r.scanned_at,
|
||||
scan_id: r.scan_id,
|
||||
};
|
||||
@@ -4493,6 +4499,43 @@ export class DatabaseService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncapped count of scans in a given status for a node. Unlike
|
||||
* `getVulnerabilityScans`, this never applies the per-image history cap, so
|
||||
* the Security overview reports the true number of (for example) failed
|
||||
* scans rather than a capped grouped total.
|
||||
*/
|
||||
public countScansByStatus(nodeId: number, status: VulnScanStatus): number {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS cnt FROM vulnerability_scans WHERE node_id = ? AND status = ?',
|
||||
)
|
||||
.get(nodeId, status) as { cnt: number }
|
||||
).cnt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of enabled block-on-deploy policies that are eligible to apply to
|
||||
* this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on
|
||||
* `getScanPoliciesForUi` so a replica never counts policies scoped to a
|
||||
* sibling node's identity. Stack-pattern applicability is not evaluated
|
||||
* (there is no concrete stack name at overview scope), so this is an
|
||||
* approximate "is this node enforcing" indicator, not a per-stack guarantee.
|
||||
*/
|
||||
public countEligibleBlockPolicies(
|
||||
nodeId: number,
|
||||
role: 'control' | 'replica',
|
||||
selfIdentity: string,
|
||||
): number {
|
||||
return this.getScanPoliciesForUi(role, selfIdentity).filter(
|
||||
(p) =>
|
||||
p.enabled === 1 &&
|
||||
p.block_on_deploy === 1 &&
|
||||
(p.node_id === null || p.node_id === nodeId),
|
||||
).length;
|
||||
}
|
||||
|
||||
// --- Scan Policies ---
|
||||
|
||||
public getScanPolicies(): ScanPolicy[] {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { VulnSeverity } from './DatabaseService';
|
||||
|
||||
/**
|
||||
* Static, read-only policy-pack catalog.
|
||||
*
|
||||
* Policy packs are curated bundles of security expectations for a deployment
|
||||
* posture (homelab, production, public edge, ...). This module is the single
|
||||
* source of truth for the catalog: it has no database, no I/O, and no
|
||||
* enforcement wiring. The Security page renders it as an educational reference
|
||||
* so operators can understand what good looks like for their environment.
|
||||
*
|
||||
* Each rule carries a stable id, a severity, plain-language explanations
|
||||
* (what it checks, why it matters, how to fix), and an `enforcement` marker.
|
||||
* `warning` rules are advisory; `enforceable` rules are the ones a future
|
||||
* enforcement phase can promote into a block-on-deploy scan policy. The marker
|
||||
* is metadata only here: nothing in this module blocks a deploy.
|
||||
*/
|
||||
|
||||
export type PolicyRuleEnforcement = 'warning' | 'enforceable';
|
||||
|
||||
export interface PolicyPackRule {
|
||||
/** Stable identifier for the rule within the catalog. */
|
||||
id: string;
|
||||
name: string;
|
||||
severity: Exclude<VulnSeverity, 'UNKNOWN'>;
|
||||
/** What the rule inspects in a Compose stack or image. */
|
||||
whatItChecks: string;
|
||||
/** Why the check matters for security or reliability. */
|
||||
why: string;
|
||||
/** Concrete remediation guidance. */
|
||||
howToFix: string;
|
||||
/** Advisory (`warning`) or promotable to a block-on-deploy policy (`enforceable`). */
|
||||
enforcement: PolicyRuleEnforcement;
|
||||
}
|
||||
|
||||
export interface PolicyPack {
|
||||
/** Stable identifier for the pack. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** One-line description of the posture the pack targets. */
|
||||
tagline: string;
|
||||
/** Descriptive copy about how the pack is meant to be used. */
|
||||
tierCopy: string;
|
||||
rules: PolicyPackRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical rule definitions, keyed by id. Packs reference these so the
|
||||
* what/why/how copy stays consistent everywhere a rule appears; each pack
|
||||
* sets its own `enforcement` level for the rule.
|
||||
*/
|
||||
type RuleDefinition = Omit<PolicyPackRule, 'enforcement'>;
|
||||
|
||||
const RULE_CATALOG = {
|
||||
'no-privileged': {
|
||||
id: 'no-privileged',
|
||||
name: 'No privileged containers',
|
||||
severity: 'CRITICAL',
|
||||
whatItChecks: 'Services that set privileged: true.',
|
||||
why: 'A privileged container can access all host devices and effectively escape isolation, so a single compromised service can take over the host.',
|
||||
howToFix: 'Remove privileged: true and grant only the specific capabilities or device mounts the workload actually needs.',
|
||||
},
|
||||
'no-docker-socket': {
|
||||
id: 'no-docker-socket',
|
||||
name: 'No Docker socket mounts',
|
||||
severity: 'CRITICAL',
|
||||
whatItChecks: 'Bind mounts of /var/run/docker.sock into a container.',
|
||||
why: 'Access to the Docker socket is equivalent to root on the host: the container can start new privileged containers or read every other stack.',
|
||||
howToFix: 'Drop the socket mount. Where a tool genuinely needs Docker access, use a scoped proxy with a read-only, filtered API surface.',
|
||||
},
|
||||
'no-host-network': {
|
||||
id: 'no-host-network',
|
||||
name: 'No host networking',
|
||||
severity: 'HIGH',
|
||||
whatItChecks: 'Services using network_mode: host.',
|
||||
why: 'Host networking removes network namespace isolation, exposes every container port on the host directly, and bypasses Compose network segmentation.',
|
||||
howToFix: 'Use a bridge network and publish only the ports you need with explicit port mappings.',
|
||||
},
|
||||
'no-broad-bind-mounts': {
|
||||
id: 'no-broad-bind-mounts',
|
||||
name: 'No broad host bind mounts',
|
||||
severity: 'HIGH',
|
||||
whatItChecks: 'Bind mounts of sensitive host paths such as /, /etc, /var, or the host home directory.',
|
||||
why: 'Mounting broad host paths lets a container read or modify host configuration and other services data, widening the blast radius of a compromise.',
|
||||
howToFix: 'Mount only the specific subdirectory the service needs, and prefer named volumes for persistent data.',
|
||||
},
|
||||
'healthcheck-defined': {
|
||||
id: 'healthcheck-defined',
|
||||
name: 'Healthcheck defined',
|
||||
severity: 'MEDIUM',
|
||||
whatItChecks: 'Services that declare no healthcheck.',
|
||||
why: 'Without a healthcheck the orchestrator cannot tell a hung container from a healthy one, so failures go unnoticed and dependent services start too early.',
|
||||
howToFix: 'Add a healthcheck with a command that reflects real readiness, plus sensible interval, timeout, and retries.',
|
||||
},
|
||||
'no-public-db-ports': {
|
||||
id: 'no-public-db-ports',
|
||||
name: 'No public database ports',
|
||||
severity: 'HIGH',
|
||||
whatItChecks: 'Database services that publish their port to the host (for example 5432, 3306, 27017, 6379).',
|
||||
why: 'Publishing a database port exposes it to anything that can reach the host, a common path to data theft on internet-adjacent machines.',
|
||||
howToFix: 'Remove the host port mapping and let other services reach the database over the internal Compose network instead.',
|
||||
},
|
||||
'run-as-non-root': {
|
||||
id: 'run-as-non-root',
|
||||
name: 'Run as non-root',
|
||||
severity: 'MEDIUM',
|
||||
whatItChecks: 'Containers that run as root when a non-root user is available.',
|
||||
why: 'Running as root raises the impact of a container breakout and of any write to a mounted host path.',
|
||||
howToFix: 'Set a non-root user with the user directive, or use an image that ships a dedicated runtime user.',
|
||||
},
|
||||
'pin-image-tag': {
|
||||
id: 'pin-image-tag',
|
||||
name: 'Pin image tags',
|
||||
severity: 'LOW',
|
||||
whatItChecks: 'Images referenced by the latest tag or with no tag at all.',
|
||||
why: 'A floating tag makes deploys non-reproducible: the same Compose file can pull different code on different days, including a regressed or compromised build.',
|
||||
howToFix: 'Pin a specific version tag, and pin a digest for the strongest guarantee.',
|
||||
},
|
||||
'restart-policy': {
|
||||
id: 'restart-policy',
|
||||
name: 'Restart policy set',
|
||||
severity: 'LOW',
|
||||
whatItChecks: 'Services with no restart policy.',
|
||||
why: 'Without a restart policy a crashed service stays down until someone notices, which turns a transient fault into an outage.',
|
||||
howToFix: 'Set restart: unless-stopped (or on-failure) so the service recovers from crashes and host reboots.',
|
||||
},
|
||||
'no-plaintext-secrets': {
|
||||
id: 'no-plaintext-secrets',
|
||||
name: 'No plaintext secrets',
|
||||
severity: 'HIGH',
|
||||
whatItChecks: 'Credentials, tokens, or keys detected in Compose files, env values, or image layers.',
|
||||
why: 'Secrets committed alongside a stack leak through backups, version control, and image registries, and are trivial to extract from a pulled image.',
|
||||
howToFix: 'Move secrets into an .env file kept out of version control, or a secrets manager, and reference them by variable.',
|
||||
},
|
||||
'resource-limits': {
|
||||
id: 'resource-limits',
|
||||
name: 'Resource limits set',
|
||||
severity: 'LOW',
|
||||
whatItChecks: 'Services with no memory or CPU limits.',
|
||||
why: 'An unbounded service can exhaust host memory or CPU and starve every other stack on the node.',
|
||||
howToFix: 'Set memory and CPU limits sized to the workload so one service cannot monopolize the host.',
|
||||
},
|
||||
'pin-digest': {
|
||||
id: 'pin-digest',
|
||||
name: 'Pin image digest',
|
||||
severity: 'LOW',
|
||||
whatItChecks: 'Images that are not pinned to a content digest.',
|
||||
why: 'A tag can be repointed at a different image after you have reviewed it; a digest is immutable and guarantees you run exactly what you vetted.',
|
||||
howToFix: 'Reference the image by digest (image@sha256:...) for workloads that need supply-chain certainty.',
|
||||
},
|
||||
'read-only-rootfs': {
|
||||
id: 'read-only-rootfs',
|
||||
name: 'Read-only root filesystem',
|
||||
severity: 'MEDIUM',
|
||||
whatItChecks: 'Containers whose root filesystem is writable.',
|
||||
why: 'A writable root filesystem lets an attacker drop tools or persist a foothold inside the container.',
|
||||
howToFix: 'Set read_only: true and mount tmpfs or named volumes for the few paths that must be writable.',
|
||||
},
|
||||
'drop-capabilities': {
|
||||
id: 'drop-capabilities',
|
||||
name: 'Drop unnecessary capabilities',
|
||||
severity: 'MEDIUM',
|
||||
whatItChecks: 'Containers that keep the default Linux capability set instead of dropping what they do not use.',
|
||||
why: 'Extra capabilities give a compromised process more ways to affect the host than the workload actually requires.',
|
||||
howToFix: 'Drop ALL capabilities and add back only the ones the service needs (cap_drop / cap_add).',
|
||||
},
|
||||
} satisfies Record<string, RuleDefinition>;
|
||||
|
||||
// Closed set of rule ids derived from the catalog. Typing `rule()` against this
|
||||
// turns a mistyped or deleted id into a compile error instead of a runtime throw.
|
||||
type RuleId = keyof typeof RULE_CATALOG;
|
||||
|
||||
function rule(id: RuleId, enforcement: PolicyRuleEnforcement): PolicyPackRule {
|
||||
return { ...RULE_CATALOG[id], enforcement };
|
||||
}
|
||||
|
||||
/**
|
||||
* The default catalog. Frozen so callers cannot mutate the shared definitions.
|
||||
* Order is intentional: gentlest posture first, strictest last.
|
||||
*/
|
||||
export const DEFAULT_POLICY_PACKS: readonly PolicyPack[] = Object.freeze([
|
||||
{
|
||||
id: 'homelab-baseline',
|
||||
name: 'Homelab baseline',
|
||||
tagline: 'Gentle defaults for a single-operator homelab.',
|
||||
tierCopy: 'Advisory guidance that flags the few habits worth keeping without getting in your way.',
|
||||
rules: [
|
||||
rule('no-plaintext-secrets', 'warning'),
|
||||
rule('pin-image-tag', 'warning'),
|
||||
rule('run-as-non-root', 'warning'),
|
||||
rule('restart-policy', 'warning'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'production-hardening',
|
||||
name: 'Production hardening',
|
||||
tagline: 'Sensible hardening for services that face real traffic.',
|
||||
tierCopy: 'Warns on risky exposure, missing healthchecks, and the highest-impact misconfigurations.',
|
||||
rules: [
|
||||
rule('no-privileged', 'enforceable'),
|
||||
rule('no-docker-socket', 'enforceable'),
|
||||
rule('no-plaintext-secrets', 'enforceable'),
|
||||
rule('no-host-network', 'warning'),
|
||||
rule('healthcheck-defined', 'warning'),
|
||||
rule('drop-capabilities', 'warning'),
|
||||
rule('read-only-rootfs', 'warning'),
|
||||
rule('pin-image-tag', 'warning'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'strict-production',
|
||||
name: 'Strict production',
|
||||
tagline: 'Zero-tolerance posture for critical workloads.',
|
||||
tierCopy: 'The strictest baseline, intended for workloads where reproducibility and isolation are non-negotiable.',
|
||||
rules: [
|
||||
rule('no-privileged', 'enforceable'),
|
||||
rule('no-docker-socket', 'enforceable'),
|
||||
rule('no-host-network', 'enforceable'),
|
||||
rule('no-broad-bind-mounts', 'enforceable'),
|
||||
rule('no-plaintext-secrets', 'enforceable'),
|
||||
rule('run-as-non-root', 'enforceable'),
|
||||
rule('healthcheck-defined', 'enforceable'),
|
||||
rule('resource-limits', 'enforceable'),
|
||||
rule('pin-digest', 'enforceable'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'public-edge',
|
||||
name: 'Public edge service',
|
||||
tagline: 'Focused on services exposed to the public internet.',
|
||||
tierCopy: 'Emphasizes secret leakage, exposed ports, and the misconfigurations that matter most at the edge.',
|
||||
rules: [
|
||||
rule('no-plaintext-secrets', 'enforceable'),
|
||||
rule('no-public-db-ports', 'enforceable'),
|
||||
rule('no-host-network', 'enforceable'),
|
||||
rule('no-privileged', 'enforceable'),
|
||||
rule('healthcheck-defined', 'warning'),
|
||||
rule('pin-image-tag', 'warning'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'internal-service',
|
||||
name: 'Internal service',
|
||||
tagline: 'Least-privilege defaults for east-west internal services.',
|
||||
tierCopy: 'Avoids public exposure and broad host access while keeping internal services easy to run.',
|
||||
rules: [
|
||||
rule('no-public-db-ports', 'warning'),
|
||||
rule('no-broad-bind-mounts', 'warning'),
|
||||
rule('run-as-non-root', 'warning'),
|
||||
rule('drop-capabilities', 'warning'),
|
||||
rule('resource-limits', 'warning'),
|
||||
rule('pin-image-tag', 'warning'),
|
||||
],
|
||||
},
|
||||
]);
|
||||
Reference in New Issue
Block a user