diff --git a/backend/src/__tests__/database-security-overview-helpers.test.ts b/backend/src/__tests__/database-security-overview-helpers.test.ts new file mode 100644 index 00000000..d207f8af --- /dev/null +++ b/backend/src/__tests__/database-security-overview-helpers.test.ts @@ -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>): 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); + }); +}); diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts new file mode 100644 index 00000000..c5e9ae05 --- /dev/null +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -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); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 1182569f..02ae627f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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 { @@ -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 => { if (!requireAdmin(req, res)) return; const svc = TrivyService.getInstance(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4a9e3e04..79a70a50 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 = {}; @@ -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[] { diff --git a/backend/src/services/policy-packs.ts b/backend/src/services/policy-packs.ts new file mode 100644 index 00000000..14348980 --- /dev/null +++ b/backend/src/services/policy-packs.ts @@ -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; + /** 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; + +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; + +// 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'), + ], + }, +]); diff --git a/docs/docs.json b/docs/docs.json index ca77adf2..a1dc1085 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -173,6 +173,7 @@ "features/rbac", "features/sso", "features/api-tokens", + "features/security", "features/vulnerability-scanning", "features/cve-suppressions", "features/private-registries" diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 6165af15..185a867a 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -313,7 +313,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli The reconciler will not auto-deploy a stateful blueprint to a node it has never run on. Click **Confirm deploy** on the row, then choose **Deploy fresh** in the dialog. Sencho will create empty named volumes and start the stack. - Open **Settings · Security · Vulnerability Scanning** and review the active scan policies for the target node. The Blueprint deployment row records the blocking policy and affected image count. Either fix the image, relax the policy, or deploy through an explicit admin-approved bypass on the stack surface. Blueprints do not silently bypass deploy enforcement. + Open the **Security** page and review the active scan policies for the target node on the **Policies** tab. The Blueprint deployment row records the blocking policy and affected image count. Either fix the image, relax the policy, or deploy through an explicit admin-approved bypass on the stack surface. Blueprints do not silently bypass deploy enforcement. Sencho accepts valid YAML up to 96 KiB. Split very large compose files into smaller blueprints or move generated content out of the Blueprint. Do not paste secrets into the compose body; use environment files or [Fleet Secrets](/features/fleet-secrets) where appropriate. diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 2c6ccb52..520c2e93 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -19,12 +19,12 @@ A suppression is a rule that says "this CVE is acknowledged." When a scan's find Badge counts in the Resources Hub continue to reflect the raw findings. Suppressions are a visual filter, not an accounting change. Remove a suppression and the underlying finding resurfaces on the next read, without rescanning. -## Creating a suppression from Settings +## Creating a suppression -Open **Settings → Security → Vulnerability Scanning** and scroll to **CVE Suppressions**, then click **Add Suppression**. +Open the **Security** page → **Suppressions** tab, then click **Add Suppression**. - Settings Security page with the CVE Suppressions panel listing two accepted CVEs, each showing the CVE ID, an outlined package badge, a clamped reason line, the author and expiry metadata, and a trash icon for removal + Security page Suppressions tab with the CVE Suppressions panel listing two accepted CVEs, each showing the CVE ID, an outlined package badge, a clamped reason line, the author and expiry metadata, and a trash icon for removal The dialog has the following fields: @@ -43,7 +43,7 @@ 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 add a Reason, an optional Image pattern, and an optional Expiry. 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 **Settings → Security → Vulnerability Scanning** instead. +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 add a Reason, an optional Image pattern, and an optional Expiry. 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. ### How specificity is resolved @@ -71,7 +71,7 @@ The same dim-and-icon treatment carries through to the **Compare** sheet, so a C Suppressions are managed on the **control** Sencho instance and replicate automatically to every remote you've registered: - Creating, editing, or removing a suppression on the control pushes the full list to every remote. -- A Sencho instance that has received at least one push from a control is a **replica**. On a replica, **Settings → Security → Vulnerability Scanning** shows the suppression list read-only, and replicated rows carry a small `replicated` badge so they are easy to tell apart from any locally-created entries. +- A Sencho instance that has received at least one push from a control is a **replica**. On a replica, the **Security** page → **Suppressions** tab shows the suppression list read-only, and replicated rows carry a small `replicated` badge so they are easy to tell apart from any locally-created entries. - When you're signed into a control and have a **remote node selected** from the node switcher, the CVE Suppressions panel itself is hidden and a "Scanner is per-node" banner explains that scanning runs on the remote while rules live on the control. The full replication, retry, and reanchor flow (including the API call to re-bind a replica to a new control) is documented in [Fleet Sync](/features/fleet-sync). @@ -80,7 +80,7 @@ The full replication, retry, and reanchor flow (including the API call to re-bin By default, suppressions do not affect [block-on-deploy policies](/features/vulnerability-scanning#honoring-suppressions-in-deploy-blocks). A policy evaluates the raw scan result, so a CVE you have suppressed still blocks a deploy that violates the threshold. Suppressions silence the noise; the gate stays strict. -To have an accepted CVE stop counting toward the gate, an admin can enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. With it on, a block-on-deploy policy re-derives each image's severity from the findings that remain after suppressions are applied, so a deploy whose only blocking findings are all suppressed proceeds without a manual bypass. Sencho records each such suppression-driven pass in the audit log. +To have an accepted CVE stop counting toward the gate, an admin can enable **Honor suppressions in deploy blocks** on the **Security** page → **Policies** tab. With it on, a block-on-deploy policy re-derives each image's severity from the findings that remain after suppressions are applied, so a deploy whose only blocking findings are all suppressed proceeds without a manual bypass. Sencho records each such suppression-driven pass in the audit log. The toggle is off by default and is set per Sencho instance, because the gate runs on whichever instance performs the deploy. @@ -101,7 +101,7 @@ Suppressed findings carry through to the [SARIF export](/features/vulnerability- Badge counts reflect the raw findings so they remain meaningful for alerting. Suppressions apply when results are read for display (the scan drawer, the Compare sheet, the SARIF export), not when they are stored. Open the scan drawer to confirm the row is dimmed with a shield-off icon. - Block-on-deploy policies evaluate the raw scan result by default, so a suppressed CVE still counts toward the block. If you want accepted CVEs to stop counting, enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning** on the instance that runs the deploy. See [Suppressions and deploy blocking](#suppressions-and-deploy-blocking) for the full behavior. + Block-on-deploy policies evaluate the raw scan result by default, so a suppressed CVE still counts toward the block. If you want accepted CVEs to stop counting, enable **Honor suppressions in deploy blocks** on the **Security** page → **Policies** tab on the instance that runs the deploy. See [Suppressions and deploy blocking](#suppressions-and-deploy-blocking) for the full behavior. Replication runs on every write. If the push failed (network blip, replica restart), the control retries every 5 minutes for 24 hours and the replica picks up the latest state on the next successful push. See [Fleet Sync](/features/fleet-sync) for how to investigate persistent push failures. diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index 9189a41b..447082c2 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -11,7 +11,7 @@ Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. Wh ## Configuring a block policy -Policies are managed under **Settings → Security → Vulnerability Scanning → Scan Policies**. The **Add policy** button opens the editor; existing policies appear as a list of cards with `max: ` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons. +Policies are managed on the **Security** page → **Policies** tab. The **Add policy** button opens the editor; existing policies appear as a list of cards with `max: ` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons. Scan Policies card showing a configured policy with the name 'Production block on critical', a max: CRITICAL badge, a block badge, and the Scope demo-blocked-* set as the stack pattern @@ -112,7 +112,7 @@ Neither drift mechanism blocks, stops, or quarantines a running stack automatica Check the following in order: - 1. Open **Settings → Security → Vulnerability Scanning** on the target node and confirm Trivy is installed. Sencho fails open when Trivy is missing, dispatching a warning alert instead of blocking. [Install Trivy](/operations/trivy-setup) to enforce the policy. + 1. Open the **Security** page → **Scanner setup** tab on the target node and confirm Trivy is installed. Sencho fails open when Trivy is missing, dispatching a warning alert instead of blocking. [Install Trivy](/operations/trivy-setup) to enforce the policy. 2. Confirm the policy is enabled and the stack pattern matches the stack name. `prod-*` matches `prod-api` but not `production-api`. An empty pattern matches every stack on the node. 3. Check the highest severity in the latest scan for each image. If no image reached the threshold, the gate correctly allowed the deploy. diff --git a/docs/features/fleet-sync.mdx b/docs/features/fleet-sync.mdx index d2a9c08a..086c5f26 100644 --- a/docs/features/fleet-sync.mdx +++ b/docs/features/fleet-sync.mdx @@ -11,20 +11,20 @@ Fleet Sync replicates three resources today, all over the same channel and with - **CVE suppressions** ([CVE Suppressions](/features/cve-suppressions)). - **Misconfig acknowledgements**. -Fleet Sync lives under **Settings → Security → Vulnerability Scanning** on both the control (where you author rules) and the replica (where you see them, read-only). +Fleet Sync lives on the **Security** page (the **Policies** and **Suppressions** tabs) on both the control (where you author rules) and the replica (where you see them, read-only). - Settings → Security → Vulnerability Scanning on the control. The Vulnerability Scanner card shows Trivy installed with Auto-update Trivy turned on. Below it, the empty 'No scan policies configured' state and the CVE Suppressions section listing two active suppressions against github.com/docker/docker, each annotated 'by admin · expires Never'. + The Security page on the control. The Scanner setup tab shows Trivy installed with Auto-update Trivy turned on. The Policies tab shows the empty 'No scan policies configured' state, and the Suppressions tab lists two active suppressions against github.com/docker/docker, each annotated 'by admin · expires Never'. ## Control and replica roles -Every Sencho instance carries a `fleet_role` flag that is either `control` or `replica`. The flag is consulted on every write path for the replicated resources and by the **Settings → Security → Vulnerability Scanning** UI when it decides whether to render the edit controls. +Every Sencho instance carries a `fleet_role` flag that is either `control` or `replica`. The flag is consulted on every write path for the replicated resources and by the **Security** page UI when it decides whether to render the edit controls. | Role | Behaviour | |---|---| | **Control** | The default for any fresh install and for the instance whose **Settings → Nodes** lists the rest of the fleet. Accepts create, edit, and delete on scan policies, CVE suppressions, and misconfig acknowledgements. Pushes the full current state of each resource to every reachable remote on every write. | -| **Replica** | An instance that has received at least one Fleet Sync push. Renders replicated rules as read-only with a "Managed by control node" banner above the policy editor on **Settings → Security → Vulnerability Scanning**. Returns `403 Forbidden` for any direct write attempt against the replicated tables. | +| **Replica** | An instance that has received at least one Fleet Sync push. Renders replicated rules as read-only with a "Managed by control node" banner above the policy editor on the **Security** page → **Policies** tab. Returns `403 Forbidden` for any direct write attempt against the replicated tables. | The transition from control to replica happens automatically the first time a replica accepts a push: the apply transaction sets `fleet_role = 'replica'` atomically with the row replacement, so the role flip and the new rows land together or not at all. Going the other way is explicit: an admin clicks **Demote to control** on the replica (see [Demote a replica](#demote-a-replica) below). @@ -38,7 +38,7 @@ What does *not* replicate: - **Trivy itself.** The scanner binary is installed independently on each instance. The Security panel on a remote shows a "Scanner is per-node" callout in place of the full editor, since the scanner lifecycle is a node concern, not a fleet concern. - Settings → Security → Vulnerability Scanning on a remote, opened via the control's node switcher. The Vulnerability Scanner card shows 'Not installed' with an Install Trivy button; below it, a 'Scanner is per-node' status callout reads 'Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.' + The Security page Scanner setup tab on a remote, opened via the control's node switcher. The Vulnerability Scanner card shows 'Not installed' with an Install Trivy button; below it, a 'Scanner is per-node' status callout reads 'Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.' - **Everything outside the three resources above.** API tokens, audit logs, blueprints, secrets, alert rules, users, SSO config, and general settings stay per-instance. - **Pilot-agent nodes.** Sync over the [pilot tunnel](/features/pilot-agent) is not part of v1; the control logs a one-time warning per pilot node and skips it during fanout. The pilot node's local rules are unaffected. @@ -95,7 +95,7 @@ Stale pushes (`409 STALE_SYNC_PUSH`) are not counted as failures, since a newer ## Demote a replica -An admin on a replica can take the instance back to a standalone control from **Settings → Security → Vulnerability Scanning**. The button is "Demote to control" and sits inside the "Managed by control node" callout. The exact modal copy: +An admin on a replica can take the instance back to a standalone control from the **Security** page → **Policies** tab. The button is "Demote to control" and sits inside the "Managed by control node" callout. The exact modal copy: > **Demote replica to control** > @@ -159,7 +159,7 @@ Fleet Sync v1 ships the three replicated resources and the control mechanics des Misconfig acknowledgements ride the same channel as scan policies and CVE suppressions; if one resource replicates and another does not, it is almost always a per-resource watermark race. Re-save the acknowledgement on the control to produce a fresh `pushedAt`, then the next push catches the remote up. If the symptom persists, check the control's server logs for the `[FleetSync]` push outcome on that remote. - Open **Settings → Security → Vulnerability Scanning** on the replica and click **Demote to control** in the "Managed by control node" callout. The action requires confirmation and drops every replicated row from this instance. Local rules authored directly on this instance are kept. The control loses this remote as a replica; remove it from the control's **Settings → Nodes** as well if you no longer want the control to push to it. + Open the **Security** page → **Policies** tab on the replica and click **Demote to control** in the "Managed by control node" callout. The action requires confirmation and drops every replicated row from this instance. Local rules authored directly on this instance are kept. The control loses this remote as a replica; remove it from the control's **Settings → Nodes** as well if you no longer want the control to push to it. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index d75d62aa..49ea3703 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -159,7 +159,7 @@ Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflow ### Vulnerability scanning -Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security → Vulnerability Scanning on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, scheduled scans, CVE suppressions, single-scan SBOM export, and auto-update of the managed Trivy binary are available on every tier; scan policies that gate deploys and SARIF export are Admiral. [Learn more →](/features/vulnerability-scanning) +Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from the Security page Scanner setup tab on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, scheduled scans, CVE suppressions, single-scan SBOM export, and auto-update of the managed Trivy binary are available on every tier; scan policies that gate deploys and SARIF export are Admiral. [Learn more →](/features/vulnerability-scanning) ### CVE suppressions diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index b2c2339f..7ba40437 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -58,7 +58,7 @@ A confirmation dialog appears before any destructive operation, with a summary o Below the hero, four tabs partition your inventory: **images**, **volumes**, **networks**, and **Unmanaged**. The Unmanaged tab shows a count badge whenever orphan containers are detected. -A **Scan history** button sits on the right of the tab strip when image vulnerability scanning is configured for the node. It opens the full scan record so you can review past results without launching a new scan. See [Vulnerability scanning](/features/vulnerability-scanning) for the full workflow. +A **Scan history** button sits on the right of the tab strip when image vulnerability scanning is configured for the node. It takes you to the [Security page](/features/security) History tab so you can review past results without launching a new scan. See [Vulnerability scanning](/features/vulnerability-scanning) for the full workflow. Sencho protects its own image, network, and named volumes from accidental deletion. The matching rows carry a **Sencho** pill alongside the managed status and the delete control is disabled. diff --git a/docs/features/security.mdx b/docs/features/security.mdx new file mode 100644 index 00000000..353acc6e --- /dev/null +++ b/docs/features/security.mdx @@ -0,0 +1,83 @@ +--- +title: "Security" +description: "The command center for your fleet's security posture: an overview dashboard, image and Compose findings, secrets, scan history, suppressions, policy packs, and scanner setup, all in one place." +--- + +Security is a primary surface in Sencho. The **Security** page in the top navigation brings the +capabilities that power [vulnerability scanning](/features/vulnerability-scanning) into one command +center, so you can answer "what should I look at first?" without hunting through other pages. It is +scoped to the active node: the findings and scanner status you see reflect whichever node is selected. + +The page is organized into tabs. + +## Overview + +The overview opens with a status masthead that reads your worst active severity at a glance, +**Secure**, **At risk**, or **Critical**, alongside the critical and high counts and the time of the +last successful scan. Below it, a signal rail summarizes the supporting numbers: scanned images, +fixable findings, secrets, Compose misconfigurations, stale scans, and failed scans. A status strip +shows scanner health (installed source and version, auto-update) and the active node's deploy +enforcement posture. + +If a node does not report an overview (for example an older remote node), the page falls back to a +clear "overview unavailable" state and the other tabs keep working. + +## Images + +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, suppress a CVE, and export an +SBOM. + +## Compose risks + +Compose risks surface misconfigurations in your stack definitions rather than image CVEs: privileged +containers, Docker socket mounts, host networking, broad host bind mounts, missing healthchecks, public +database ports, containers running as root, unpinned image tags, and missing restart policies. Each +entry opens its scan report with the specific findings and how to fix them. The [Policy packs](#policy-packs) +tab explains each category in depth. + +## Secrets + +The secrets tab lists images where Trivy detected exposed credentials or keys, and opens straight to +the secret findings for a scan. + +## Policy packs + +Policy packs are curated bundles of security expectations for a deployment posture. Sencho ships five +defaults: + +- **Homelab baseline**: gentle defaults for a single-operator homelab. +- **Production hardening**: sensible hardening for services that face real traffic. +- **Strict production**: a zero-tolerance posture for critical workloads. +- **Public edge service**: focused on services exposed to the public internet. +- **Internal service**: least-privilege defaults for east-west internal services. + +Each pack lists its rules with the severity, what the rule checks, why it matters, and how to fix it. +Every rule is marked **warning** or **enforceable**. In Community, packs are advisory: they explain +what good looks like. Block-on-deploy enforcement is an Admiral capability, configured under +[scan policies](/features/vulnerability-scanning#scan-policies). + +## Suppressions + +CVE suppressions and misconfiguration acknowledgements are managed here, the same controls available +in Settings. These are governed by the local instance, so this tab is shown when you are on the local +node; switch to the local node to manage them. + +## History + +The History tab opens the scan history sheet, listing completed scans grouped by image with search and +two-scan comparison. Closing the sheet leaves the tab in place so you can reopen it. The **Scan +history** button in the [Resources Hub](/features/resources) is a shortcut to the same place. + +## Scanner setup + +Scanner setup manages the Trivy binary for the active node: install the managed binary, update it when +a new release is available, and toggle automatic updates. Trivy is installed independently on each +node. See [Installing Trivy](/operations/trivy-setup) for the full setup options. + +## What stays where + +Resources keeps its per-image severity badges and scan actions, so you can still scan and inspect an +image from your inventory. Security is the place to review the whole picture; the deeper scanning +workflow (on-demand scans, scheduled fleet scans, scan policies, SBOM and SARIF export) is documented +under [Vulnerability scanning](/features/vulnerability-scanning). diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index bb94d196..c359aef6 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -3,7 +3,7 @@ title: "Vulnerability Scanning" description: "Scan container images and stack compose files for CVEs, secrets, and misconfigurations. Surface severity badges in the Resources Hub, compare scans over time, and gate deploys on policy violations." --- -Sencho integrates with [Trivy](https://trivy.dev) to scan container images and Compose files for vulnerabilities (CVEs), hardcoded secrets, and misconfigurations. Findings surface as severity badges in the Resources Hub and as drillable reports in the scan drawer. Manual scanning, secret and misconfig detection, scan history, comparison, scheduled fleet scans, CVE suppressions, single-scan SBOM export, and managed Trivy auto-update are available on every tier. Admiral adds policy enforcement and SARIF export. +Sencho integrates with [Trivy](https://trivy.dev) to scan container images and Compose files for vulnerabilities (CVEs), hardcoded secrets, and misconfigurations. Findings surface as severity badges in the Resources Hub and as drillable reports in the scan drawer. The dedicated [Security page](/features/security) is the command center for risk review: overview, image findings, Compose risks, secrets, scan history, suppressions, policy packs, and scanner setup all live there. Manual scanning, secret and misconfig detection, scan history, comparison, scheduled fleet scans, CVE suppressions, single-scan SBOM export, and managed Trivy auto-update are available on every tier. Admiral adds policy enforcement and SARIF export. Resources Hub Images table with severity badges (CRITICAL, HIGH, MEDIUM) on managed image rows alongside the Scan history button @@ -14,7 +14,7 @@ Sencho integrates with [Trivy](https://trivy.dev) to scan container images and C The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available. - Trivy is installed independently on each Sencho instance. When you select a remote node, **Settings → Security → Vulnerability Scanning** shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies, CVE suppressions, and misconfig acknowledgements are managed on the control instance and replicate fleet-wide. + Trivy is installed independently on each Sencho instance. When you select a remote node, the **Security** page → **Scanner setup** tab shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies, CVE suppressions, and misconfig acknowledgements are managed on the control instance and replicate fleet-wide. ## Tier availability @@ -144,12 +144,12 @@ Policies define severity thresholds that govern whether a stack can deploy. A po See [Deploy Enforcement](/features/deploy-enforcement) for the full pre-flight flow, admin bypass path, and audit-log behavior. - Settings Security page showing the masthead with Node, Policies, and Trivy status, the Vulnerability Scanner card with Auto-update Trivy toggle, an Add Policy button, and the No scan policies configured empty state + The Security page Policies tab showing the policy-pack catalog, the Deploy enforcement policies section with an Add policy button, and the No scan policies configured empty state ### Creating a policy -Go to **Settings → Security → Vulnerability Scanning** and click **Add Policy**. +Go to the **Security** page → **Policies** tab and click **Add policy**. | Field | Description | |-------|-------------| @@ -195,7 +195,7 @@ Only one policy is evaluated per deploy. Use a single tight pattern rather than By default a block-on-deploy policy evaluates the **raw** scan result, so a CVE you have accepted in [CVE Suppressions](/features/cve-suppressions) still counts toward the block. That keeps the gate strict: suppressions silence alerts and dim findings in reports, but on their own they do not open the deploy path. -To have an accepted CVE stop counting toward the gate, turn on **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. With it on, the gate re-derives each image's severity from the findings that remain after suppressions are applied, so an image whose only blocking findings are all suppressed deploys without a manual bypass. When a deploy proceeds for this reason, Sencho records it in the audit log so the suppression-driven pass stays traceable. +To have an accepted CVE stop counting toward the gate, turn on **Honor suppressions in deploy blocks** on the **Security** page → **Policies** tab. With it on, the gate re-derives each image's severity from the findings that remain after suppressions are applied, so an image whose only blocking findings are all suppressed deploys without a manual bypass. When a deploy proceeds for this reason, Sencho records it in the audit log so the suppression-driven pass stays traceable. The toggle governs the Sencho instance that runs the deploy and is off by default. Enable it on each instance whose deploys should honor suppressions. @@ -272,7 +272,7 @@ Acknowledged rows render dimmed with a strikethrough title; hovering surfaces th ### Managing acknowledgements -**Settings → Security → Vulnerability Scanning** has a Misconfig Acknowledgements panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica. +The **Security** page → **Suppressions** tab has a Misconfig Acknowledgements panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica. Replicas show the panel read-only; write operations return 403 with a "managed by control" message so configuration drift cannot accumulate on the leaf nodes. @@ -336,7 +336,7 @@ Typical upload flow for GitHub code scanning: Every scan Sencho runs is stored with its full vulnerability detail. Scan records are pruned automatically after 90 days to keep the database compact. The history powers two things: digest caching (skip re-scanning a digest already scanned within 24 hours) and trend insight (compare a new scan to its predecessor to see what changed). -Click **Scan history** from the top of the Resources Hub to open the scan history sheet over the current page. The sheet lists completed scans grouped by image, lets you search by image reference, and lets you tick two scans to compare. Close the sheet with Escape, by clicking the overlay, or by clicking the close button in the header. +Open the **Security** page and select the **History** tab, then choose **Open scan history**, to browse completed scans. The sheet lists completed scans grouped by image, lets you search by image reference, and lets you tick two scans to compare. Close the sheet with Escape, by clicking the overlay, or by clicking the close button in the header; the History tab stays open behind it. The **Scan history** button at the top of the Resources Hub is a shortcut to the same place. Scan history sheet over the Resources Hub showing 361 scans across 29 images, the Compare primary action enabled with two scans ticked, a search box, pagination, and scans grouped by image reference @@ -378,7 +378,7 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this - Sencho hides scanning UI when the Trivy binary is not detected. Check **Settings → Security → Vulnerability Scanning** for the scanner status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing. + Sencho hides scanning UI when the Trivy binary is not detected. Check the **Security** page → **Scanner setup** tab for the scanner status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing. The default scan timeout is 5 minutes. Very large images (2 GB or more) over a slow connection may exceed this; pre-pulling the image to the host speeds up the scan significantly because Trivy then works against the local image store. @@ -402,10 +402,10 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this When a post-deploy scan fails for a specific image (for example because Trivy could not resolve a private registry pull), Sencho dispatches a warning-level alert through your configured notification channels. The deploy itself is never blocked by a scan failure. - The block dialog names the policy that fired and lists every image that violated the threshold. Open **Settings → Security → Vulnerability Scanning** and review the matching policy: check the stack pattern glob and the max severity. If the policy should not apply, tighten the pattern (for example `staging-*` instead of `*`) or turn **Block on deploy** off to keep the evaluation in alert-only mode. Admins can bypass a single deploy with the **Deploy anyway** button; every bypass is recorded in the [Audit Log](/features/audit-log) with the actor, policy, and violation list. + The block dialog names the policy that fired and lists every image that violated the threshold. Open the **Security** page → **Policies** tab and review the matching policy: check the stack pattern glob and the max severity. If the policy should not apply, tighten the pattern (for example `staging-*` instead of `*`) or turn **Block on deploy** off to keep the evaluation in alert-only mode. Admins can bypass a single deploy with the **Deploy anyway** button; every bypass is recorded in the [Audit Log](/features/audit-log) with the actor, policy, and violation list. - Sencho fails open when Trivy is not installed on the target node, so operators are never locked out by tooling state. A warning alert is dispatched through your configured notification channels with the message `Pre-deploy scan for "" skipped: Trivy not installed on this node`. Install Trivy from **Settings → Security → Vulnerability Scanning** to enforce the policy; see [Installing Trivy](/operations/trivy-setup) for options. + Sencho fails open when Trivy is not installed on the target node, so operators are never locked out by tooling state. A warning alert is dispatched through your configured notification channels with the message `Pre-deploy scan for "" skipped: Trivy not installed on this node`. Install Trivy from the **Security** page → **Scanner setup** tab to enforce the policy; see [Installing Trivy](/operations/trivy-setup) for options. The Compare primary action enables only after exactly two scans are ticked. Selecting zero, one, or three scans leaves it disabled. If you have only one scan for an image, trigger a second scan from the Resources Hub (or wait for a scheduled scan), then return to Scan history and tick both. @@ -420,10 +420,10 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this The Scan history sheet uses server-driven pagination. If you know the scan exists but cannot see it, use the search box to filter by image reference, or page forward with the arrows in the card header. Scans older than 90 days are pruned automatically to keep the database compact. - Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, **Settings → Security → Vulnerability Scanning** shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures. + Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, the **Security** page → **Policies** tab shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures. - Badge counts always reflect raw findings so alerting stays accurate. Policy evaluation also uses raw findings by default; if you want suppressed CVEs to stop counting toward block-on-deploy policies, enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces. + Badge counts always reflect raw findings so alerting stays accurate. Policy evaluation also uses raw findings by default; if you want suppressed CVEs to stop counting toward block-on-deploy policies, enable **Honor suppressions in deploy blocks** on the **Security** page → **Policies** tab. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces. Secret detection matches against Trivy's built-in rule set, which focuses on well-known provider patterns. Plain text passwords, custom token formats, or values that do not match any published rule will not appear. Make sure you picked **Full scan (vulnerabilities + secrets)** from the shield-icon menu; a plain vulnerability scan does not walk the filesystem. diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 109c4857..9fa5440e 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -5,7 +5,7 @@ description: Install and mount the Trivy CLI so Sencho can scan container images Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image. You have three ways to provide it, in order of convenience: -1. **One-click install from Settings → Security → Vulnerability Scanning** (recommended). +1. **One-click install from the [Security page](/features/security) → Scanner setup tab** (recommended). Install, update, uninstall, and auto-update all live there. 2. Bind mount a host Trivy binary into the container. 3. Build a custom Sencho image with Trivy baked in. @@ -23,12 +23,12 @@ Trivy's vulnerability database updates multiple times per day and is around 100 Sencho can install and manage Trivy for you without any extra bind mounts or environment variables. -1. Go to **Settings → Security → Vulnerability Scanning**. +1. Open the **Security** page and select the **Scanner setup** tab. 2. Under **Vulnerability Scanner**, click **Install Trivy**. 3. Wait for the status to flip to **Installed (managed)**. The version appears next to the badge. - Vulnerability Scanner card in Settings, Security section, with Install Trivy button + Vulnerability Scanner card on the Security page Scanner setup tab, with Install Trivy button Behind the scenes: @@ -40,7 +40,7 @@ Behind the scenes: ### Updating the managed install -When a newer Trivy release is available, Settings → Security → Vulnerability Scanning shows an **Update available** badge next to the version. Click **Update** to pull the latest release. +When a newer Trivy release is available, the Security page Scanner setup tab shows an **Update available** badge next to the version. Click **Update** to pull the latest release. To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. @@ -181,7 +181,7 @@ Plan to refresh the bundle on a schedule (weekly is typical) so CVE data stays c ## Verifying Sencho detects Trivy -1. Open **Settings → Security → Vulnerability Scanning**. The **Vulnerability Scanner** card shows the current status and version. +1. Open the **Security** page → **Scanner setup** tab. The **Vulnerability Scanner** card shows the current status and version. 2. Open the **Resources** tab. If Trivy is detected, a shield icon appears in the Actions column of the **Images** panel next to the delete icon on every row. If the scanner shows as not installed after using Option 2 or 3, see the troubleshooting section below. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 29109a1c..e51b9d52 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -20,11 +20,12 @@ Open the Settings Hub by clicking the **Profile** icon in the top bar and select | **Notifications** | Channels, Notification Routing | | **Automation** | Webhooks | | **Organization** | Labels | -| **Security** | Vulnerability Scanning | | **Operations** | Data Retention, Developer Diagnostics, Recovery | | **Help** | Support, About | -Sections that require a higher license tier stay hidden until the operator has the matching license. Admin-only sections (Users, SSO, API Tokens, Fleet Mesh, Registries, Cloud Backup, Notification Routing, Vulnerability Scanning) stay hidden for non-admin operators. +Vulnerability scanning is no longer a Settings section: scanner setup, scan policies, suppressions, and acknowledgements now live on the dedicated [Security page](/features/security). + +Sections that require a higher license tier stay hidden until the operator has the matching license. Admin-only sections (Users, SSO, API Tokens, Fleet Mesh, Registries, Cloud Backup, Notification Routing) stay hidden for non-admin operators. ### Page chrome @@ -35,11 +36,11 @@ Every section renders inside the same masthead-and-sidebar layout. The masthead | **SCOPE** `operator` / `global` | Setting applies to your account or this browser (Personal sections) or to the whole instance (every other non-node group) | | **NODE** `` | Setting is per-node and is currently being edited against this node | | **EDITED** `` pending / `saved` | The current section has unsaved changes | -| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP left` (Account); `PLAN`, `TRIAL d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `TRIVY managed`/`host`/`missing` and `POLICIES` (Vulnerability Scanning); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) | +| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP left` (Account); `PLAN`, `TRIAL d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) | ### Quick search -Click **Filter** at the top of the sidebar, or press `Ctrl+K` / `⌘K` while the hub is open, to open the command palette. Type any section name, keyword, or synonym (for example, `saml` finds SSO and `trivy` finds Security) and press Enter to jump to it. +Click **Filter** at the top of the sidebar, or press `Ctrl+K` / `⌘K` while the hub is open, to open the command palette. Type any section name, keyword, or synonym (for example, `saml` finds SSO) and press Enter to jump to it. Settings command palette filtered to a webhook query @@ -432,44 +433,6 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough. --- -## Vulnerability Scanning - - - Vulnerability Scanning is admin-only. The Trivy installer, the **Auto-update Trivy** toggle (which also requires a managed Trivy binary), and CVE/misconfig suppressions are available on all tiers; scan policies require an Admiral license. - - -**Scope:** Per-node - -Manage the Trivy scanner, scan policies, suppressions, and acknowledgements that power [vulnerability scanning](/features/vulnerability-scanning) across the fleet. The masthead publishes a **TRIVY** pill (`managed` / `host` / `missing`) and a **POLICIES** pill with the active rule count. - - - Security section showing the Trivy installer card and the empty scan-policy state - - -### Trivy scanner - -| Element | Description | -|---------|-------------| -| **Status** | `Installed (managed)` when Sencho manages the binary, `Installed (host)` when an existing host binary is being reused, or empty when nothing is detected. | -| **Version** | The current Trivy version, when installed. | -| **Install / Update / Uninstall** | Lifecycle actions for the managed binary. Uninstall asks for confirmation. | -| **Auto-update Trivy** toggle | When on, Sencho checks daily and installs newer Trivy releases automatically. Requires a managed Trivy binary. | - -### Scan policies - -A scan policy enforces a severity threshold on a set of stacks. Each policy carries a severity level (`CRITICAL`, `HIGH`, `MEDIUM`, `LOW`), a stack pattern (regex), a **Block on deploy** toggle, and an **Enabled** toggle. Use **Add policy** to create one. - -### CVE Suppressions and Misconfig Acknowledgements - -- **CVE Suppressions** are fleet-wide accepted CVEs. They apply at read time across every instance and never modify stored scan data. See [CVE Suppressions](/features/cve-suppressions). -- **Misconfig Acknowledgements** are fleet-wide accepted misconfigurations, with the same read-time semantics. - -### Fleet role - -Sencho instances on the Admiral tier can act as a security control plane for the fleet or as a replica. When this instance is a replica, the section shows a **Managed by control node** banner and a **Demote to control** button that converts the replica back into a standalone control. - ---- - ## Data Retention **Scope:** Per-node diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 910d0fb0..14aba8e5 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -156,6 +156,7 @@ export default function EditorLayout() { const { activeView, setActiveView, settingsSection, setSettingsSection, + securityTab, setSecurityTab, securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, schedulePrefill, @@ -704,6 +705,8 @@ export default function EditorLayout() { onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => openSettings(section)} onClearNotifications={clearAllNotifications} + securityTab={securityTab} + onSecurityTabChange={setSecurityTab} renderEditor={renderEditor} /> diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 2b395fbf..ae10c9f2 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -13,6 +13,7 @@ import HomeDashboard from '../HomeDashboard'; import type { NotificationItem } from '../dashboard/types'; import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; import type { ActiveView } from './hooks/useViewNavigationState'; +import type { SecurityTab } from '@/lib/events'; // Paid-tier views and the security-history overlay are loaded on demand. // Their internal PaidGate / CapabilityGate wrappers render @@ -39,6 +40,9 @@ const AuditLogView = lazy(() => ); const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView')); const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView')); +const SecurityView = lazy(() => + import('../SecurityView').then(m => ({ default: m.SecurityView })), +); // Sized for the main workspace area (flex-1 with p-6 padding). Visible // only during the brief window between an unlocked view's chunk request @@ -81,6 +85,8 @@ export interface ViewRouterProps { onNavigateToStack: (stackFile: string) => void; onOpenSettingsSection: (section: SectionId) => void; onClearNotifications: () => void; + securityTab: SecurityTab; + onSecurityTabChange: (tab: SecurityTab) => void; // Render slot for the inline editor view. Kept as a callback so the // (large) editor JSX is only allocated when activeView === 'editor', // not on every parent render that lands on a different view. @@ -104,6 +110,8 @@ export function ViewRouter({ onNavigateToStack, onOpenSettingsSection, onClearNotifications, + securityTab, + onSecurityTabChange, renderEditor, }: ViewRouterProps): ReactNode { const { can } = useAuth(); @@ -121,6 +129,16 @@ export function ViewRouter({ if (activeView === 'resources') { return ; } + if (activeView === 'security') { + // Node-scoped (not hub-only): scan/scanner data follows the active node + // like Resources. The page itself is Community; per-tab gates handle + // capability-missing nodes and the local-control governance tabs. + return ( + + + + ); + } if (activeView === 'host-console') { // Mirror the backend RBAC gate (system:console, admin-only). The nav // item is already admin-gated; this stops a non-admin who reaches the diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index bda6746b..f8362a00 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -332,4 +332,46 @@ describe('useViewNavigationState', () => { expect(result.current.activeView).toBe('resources'); expect(onNavigateToDashboard).not.toHaveBeenCalled(); }); + + // ── Security view: node-scoped, deep-linkable tab ────────────────────────── + + it('includes the Security nav item for a community user', () => { + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.navItems.map(i => i.value)).toContain('security'); + }); + + it('keeps Security visible on a remote node (node-scoped, not hub-only)', () => { + mockActiveNode('remote'); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.navItems.map(i => i.value)).toContain('security'); + }); + + it('defaults securityTab to overview', () => { + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.securityTab).toBe('overview'); + }); + + it('navigate to security with a tab sets securityTab then activeView (deep-link, no race)', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'history', nodeId: 4 } }), + ); + }); + expect(result.current.activeView).toBe('security'); + expect(result.current.securityTab).toBe('history'); + expect(result.current.filterNodeId).toBe(4); + }); + + it('navigate to security without a tab defaults securityTab to overview', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => result.current.setSecurityTab('history')); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security' } }), + ); + }); + expect(result.current.activeView).toBe('security'); + expect(result.current.securityTab).toBe('overview'); + }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 6313811c..f0721f0e 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Terminal, CloudDownload, Home, HardDrive, ScrollText, - Activity, Radar, RefreshCw, Clock, + Activity, Radar, RefreshCw, Clock, ShieldCheck, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; @@ -9,6 +9,7 @@ import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager'; import type { SenchoNavigateDetail } from '@/components/NodeManager'; +import type { SecurityTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView'; @@ -20,6 +21,7 @@ export type ActiveView = | 'templates' | 'global-observability' | 'fleet' + | 'security' | 'audit-log' | 'scheduled-ops' | 'auto-updates' @@ -58,6 +60,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const [activeView, setActiveView] = useState('dashboard'); const [settingsSection, setSettingsSection] = useState('appearance'); + const [securityTab, setSecurityTab] = useState('overview'); const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); @@ -91,6 +94,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) setFilterNodeId(detail.nodeId ?? null); return; } + if (detail.view === 'security') { + // Set the target tab before switching the view so the controlled + // SecurityView lands on it deterministically (no mount race). + setSecurityTab(detail.tab ?? 'overview'); + setActiveView('security'); + setFilterNodeId(detail.nodeId ?? null); + return; + } setActiveView(detail.view as ActiveView); setFilterNodeId(detail.nodeId ?? null); }; @@ -103,6 +114,9 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) { value: 'dashboard', label: 'Home', icon: Home }, { value: 'fleet', label: 'Fleet', icon: Radar }, { value: 'resources', label: 'Resources', icon: HardDrive }, + // Security is a Community, node-scoped review surface (not hub-only), so + // it shows for every authenticated user and on remote nodes too. + { value: 'security', label: 'Security', icon: ShieldCheck }, { value: 'templates', label: 'App Store', icon: CloudDownload }, ]; // The aggregated Logs feed crosses every managed stack, so it is an @@ -137,6 +151,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) return { activeView, setActiveView, settingsSection, setSettingsSection, + securityTab, setSecurityTab, securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, schedulePrefill, setSchedulePrefill, diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts index d593bb92..d40e0824 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts @@ -13,6 +13,10 @@ describe('mobile treatments', () => { } }); + it('treats the Security view as responsive (reflowed, not bespoke or desktop-only)', () => { + expect(MOBILE_TREATMENTS.security).toBe('responsive'); + }); + it('keeps BESPOKE_MOBILE_VIEWS in lockstep with the bespoke treatments', () => { const declaredBespoke = Object.entries(MOBILE_TREATMENTS) .filter(([, treatment]) => treatment === 'bespoke') diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts index 01b580ea..70dcee31 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.ts @@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record = { settings: 'bespoke', editor: 'detail', resources: 'responsive', + security: 'responsive', templates: 'responsive', 'global-observability': 'responsive', 'auto-updates': 'responsive', diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index cd780d7c..873f7007 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -19,6 +19,7 @@ import { useAuth } from '@/context/AuthContext'; import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions'; import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi'; +import type { SecurityTab } from '@/lib/events'; interface NodeSchedulingSummary { active_tasks: number; @@ -29,8 +30,10 @@ interface NodeSchedulingSummary { export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate'; export interface SenchoNavigateDetail { - view: 'scheduled-ops' | 'auto-updates' | 'security-history'; + view: 'scheduled-ops' | 'auto-updates' | 'security-history' | 'security'; nodeId?: number; + /** Target tab when navigating to the Security view. */ + tab?: SecurityTab; } export function NodeManager() { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 78934e9b..8ee13e71 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -16,11 +16,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen } from 'lucide-react'; -import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; +import { SeverityBadge } from '@/components/ui/SeverityBadge'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'; -import type { ScanSummary, VulnSeverity } from '@/types/security'; +import type { ScanSummary } from '@/types/security'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; @@ -227,84 +227,6 @@ function SenchoBadge() { // ── Severity Badge ───────────────────────────────────────────────────────────── -const SEVERITY_BADGE_CLASSES: Record = { - CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive', - HIGH: 'border-warning/25 bg-warning/8 text-warning', - MEDIUM: 'border-warning/25 bg-warning/8 text-warning', - LOW: 'border-border bg-muted/30 text-muted-foreground', - UNKNOWN: 'border-border bg-muted/20 text-muted-foreground', - CLEAN: 'border-success/25 bg-success/8 text-success', -}; - -const SEVERITY_DOT_CLASSES: Record = { - CRITICAL: 'bg-destructive', - HIGH: 'bg-warning', - MEDIUM: 'bg-warning', - LOW: 'bg-muted-foreground/60', - UNKNOWN: 'bg-muted-foreground/40', - CLEAN: 'bg-success', -}; - -function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) { - const key: VulnSeverity | 'CLEAN' = summary.highest_severity ?? 'CLEAN'; - const label = key === 'CLEAN' ? 'Clean' : key; - const [relative, setRelative] = useState(''); - useEffect(() => { - const compute = () => { - const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000); - setRelative( - scanAge < 1 ? 'just now' - : scanAge < 60 ? `${scanAge}m ago` - : scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago` - : `${Math.round(scanAge / 1440)}d ago`, - ); - }; - compute(); - const id = setInterval(compute, 60000); - return () => clearInterval(id); - }, [summary.scanned_at]); - - return ( - - - - - -
- - -
-
-
Last scanned
-
{relative}
- {summary.total > 0 && ( -
- {summary.critical > 0 && {summary.critical}C} - {summary.high > 0 && {summary.high}H} - {summary.medium > 0 && {summary.medium}M} - {summary.low > 0 && {summary.low}L} -
- )} - {summary.total === 0 && ( -
No vulnerabilities
- )} -
-
-
- - ); -} - // ── Quick Clean Prune Button ─────────────────────────────────────────────────── interface PruneButtonProps { @@ -920,7 +842,7 @@ export default function ResourcesView() { className="border-border" onClick={() => { window.dispatchEvent(new CustomEvent(SENCHO_NAVIGATE_EVENT, { - detail: { view: 'security-history' }, + detail: { view: 'security', tab: 'history' }, })); }} title="View completed vulnerability scans and compare them" diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx new file mode 100644 index 00000000..cf6b9ab4 --- /dev/null +++ b/frontend/src/components/SecurityView.tsx @@ -0,0 +1,270 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + LayoutDashboard, Boxes, FileWarning, KeyRound, BookCheck, EyeOff, History as HistoryIcon, Wrench, Info, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; +import { PageMasthead } from '@/components/ui/PageMasthead'; +import { CapabilityGate } from '@/components/CapabilityGate'; +import { deriveMasthead } from './security/securityMasthead'; +import { springs } from '@/lib/motion'; +import { apiFetch } from '@/lib/api'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import { useLicense } from '@/context/LicenseContext'; +import { useAuth } from '@/context/AuthContext'; +import { useNodes } from '@/context/NodeContext'; +import type { SecurityTab } from '@/lib/events'; +import type { SecurityOverview, ScanSummary, ScanDetailTab, FleetRole } from '@/types/security'; +import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; +import { SecurityHistoryView } from './SecurityHistoryView'; +import { SuppressionsPanel } from './settings/SuppressionsPanel'; +import { MisconfigAckPanel } from './settings/MisconfigAckPanel'; +import { OverviewTab } from './security/OverviewTab'; +import { ImagesTab } from './security/ImagesTab'; +import { FindingsTab } from './security/FindingsTab'; +import { PolicyPacksTab } from './security/PolicyPacksTab'; +import { ScanPolicyManager } from './security/ScanPolicyManager'; +import { ScannerSetupTab } from './security/ScannerSetupTab'; + +interface SecurityViewProps { + activeTab: SecurityTab; + onTabChange: (tab: SecurityTab) => void; +} + +export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { + const { isPaid } = useLicense(); + const { isAdmin } = useAuth(); + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; + + const [overview, setOverview] = useState(null); + // 'unsupported' = the node has no overview endpoint (e.g. an older remote, 404); + // 'failed' = a genuine error (5xx, network, malformed body) that must not read as benign. + const [overviewLoadError, setOverviewLoadError] = useState<'unsupported' | 'failed' | null>(null); + const [summaries, setSummaries] = useState>({}); + const [summariesLoading, setSummariesLoading] = useState(true); + const [summariesError, setSummariesError] = useState(false); + const [isReplica, setIsReplica] = useState(false); + + const [inspectScanId, setInspectScanId] = useState(null); + const [inspectInitialTab, setInspectInitialTab] = useState(undefined); + const [historyOpen, setHistoryOpen] = useState(false); + + const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => { + setInspectInitialTab(initialTab); + setInspectScanId(scanId); + }, []); + + // Active-node scoped data: overview rollup + image summaries follow x-node-id. + // A failed fetch (5xx, network, malformed body) must surface as an error, never + // as a benign "clean / no findings" view, which for a security surface is the + // most dangerous misread. A 404 on /overview is the one benign case (an older + // remote node that lacks the endpoint). + useEffect(() => { + let cancelled = false; + (async () => { + setSummariesLoading(true); + setOverviewLoadError(null); + setSummariesError(false); + try { + const [overviewRes, summariesRes] = await Promise.all([ + apiFetch('/security/overview'), + apiFetch('/security/image-summaries'), + ]); + if (cancelled) return; + if (overviewRes.ok) { + setOverview(await overviewRes.json()); + } else { + setOverview(null); + setOverviewLoadError(overviewRes.status === 404 ? 'unsupported' : 'failed'); + if (overviewRes.status !== 404) { + console.warn('[Security] overview request failed:', overviewRes.status); + } + } + if (summariesRes.ok) { + setSummaries(await summariesRes.json()); + } else { + setSummaries({}); + setSummariesError(true); + console.warn('[Security] image-summaries request failed:', summariesRes.status); + } + } catch (err) { + if (cancelled) return; + console.warn('[Security] failed to load security data:', err); + setOverview(null); + setOverviewLoadError('failed'); + setSummaries({}); + setSummariesError(true); + } finally { + if (!cancelled) setSummariesLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [activeNode?.id]); + + // Governance panels (suppressions/acks) are control-governed; probe the local + // fleet role so a replica renders them read-only, mirroring Settings. + useEffect(() => { + if (isRemote) return; + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/fleet/role', { localOnly: true }); + if (!res.ok || cancelled) return; + const data = await res.json(); + if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) { + setIsReplica((data.role as FleetRole) === 'replica'); + } + } catch { + // Treat as control on probe failure (read-only gate is best-effort). + } + })(); + return () => { cancelled = true; }; + }, [isRemote, activeNode?.id]); + + // A deep-link to History (e.g. the Resources "Scan history" button, which + // mounts this view with the History tab active) auto-opens the sheet once on + // mount. Selecting the History tab manually shows the persistent launcher + // body instead, so the sheet does not pop on every tab click; closing it + // always leaves the launcher. + const deepLinkedToHistory = useRef(activeTab === 'history'); + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (deepLinkedToHistory.current) setHistoryOpen(true); + }, []); + + const { state, tone } = deriveMasthead(overview, overviewLoadError !== null); + const pulsing = tone === 'live' && !!overview?.scanner.available; + + return ( +
+ 0 ? 'error' : 'value' }, + { label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' }, + { label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' }, + ] : undefined} + /> + + onTabChange(v as SecurityTab)}> + + + + Overview + + + Images + + + Compose risks + + + Secrets + + + + Policies + + + Suppressions + + + History + + + Scanner setup + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + {isRemote ? ( +
+
+ ) : ( +
+ + +
+ )} +
+ + + +
+
+

Scan history

+

+ {overview + ? `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned · last scan ${overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}` + : 'Browse completed scans and compare them.'} +

+
+ +
+
+
+ + + + +
+ + setHistoryOpen(false)} /> + + setInspectScanId(null)} + canGenerateSbom={isAdmin} + canExportSarif={isPaid && isAdmin} + canCompare + canManageSuppressions={isAdmin} + /> +
+ ); +} diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index b5734a92..f470c996 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -52,6 +52,7 @@ import type { VulnSeverity, SecretFinding, MisconfigFinding, + ScanDetailTab, } from '@/types/security'; interface VulnerabilityScanSheetProps { @@ -62,6 +63,13 @@ interface VulnerabilityScanSheetProps { canExportSarif?: boolean; canCompare?: boolean; canManageSuppressions?: boolean; + /** + * Tab to open on first load. Defaults to 'vulns' (with the existing + * auto-switch to a populated tab when the scan has no vulnerabilities). + * Callers that open the sheet from a secret/misconfig context pass the + * matching tab so it lands there even when the scan also has CVEs. + */ + initialTab?: FindingTab; } interface SuppressDialogState { @@ -80,7 +88,9 @@ interface AckDialogState { } type SeverityFilter = 'ALL' | VulnSeverity; -type FindingTab = 'vulns' | 'secrets' | 'misconfigs'; +// Single source of truth lives in types/security as ScanDetailTab; alias here so +// the initialTab prop is provably the same type its callers (SecurityView) hold. +type FindingTab = ScanDetailTab; const PAGE_SIZE = 25; @@ -113,6 +123,7 @@ export function VulnerabilityScanSheet({ canExportSarif = false, canCompare = false, canManageSuppressions: canManageSuppressionsProp = false, + initialTab, }: VulnerabilityScanSheetProps) { const [isReplica, setIsReplica] = useState(false); useEffect(() => { @@ -183,7 +194,11 @@ export function VulnerabilityScanSheet({ setPage(0); setSecretsPage(0); setMisconfigsPage(0); - if ((scanData.total_vulnerabilities ?? 0) === 0) { + if (initialTab) { + // Caller asked to land on a specific tab (e.g. opened from the + // Secrets or Compose-risks list), which wins over the default. + setTab(initialTab); + } else if ((scanData.total_vulnerabilities ?? 0) === 0) { if ((scanData.misconfig_count ?? 0) > 0) setTab('misconfigs'); else if ((scanData.secret_count ?? 0) > 0) setTab('secrets'); else setTab('vulns'); @@ -195,7 +210,7 @@ export function VulnerabilityScanSheet({ } finally { setLoading(false); } - }, [scanId]); + }, [scanId, initialTab]); useEffect(() => { setCompareOpen(false); diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 521cba63..4e505838 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -3,6 +3,7 @@ import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react'; import { formatCount } from '@/lib/utils'; import { useConfigurationStatus } from './useConfigurationStatus'; import type { SectionId } from '@/components/settings/types'; +import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager'; interface ConfigurationStatusProps { onOpenSection?: (section: SectionId) => void; @@ -191,7 +192,9 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'policies' } }), + )} /> )} diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx index 42bceb24..fa405a23 100644 --- a/frontend/src/components/mobile/MobileSettings.tsx +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -54,7 +54,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) { {item.label}
- +
); diff --git a/frontend/src/components/security/FindingsTab.tsx b/frontend/src/components/security/FindingsTab.tsx new file mode 100644 index 00000000..981e2bf9 --- /dev/null +++ b/frontend/src/components/security/FindingsTab.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { KeyRound, FileWarning, AlertTriangle } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SeverityBadge } from '@/components/ui/SeverityBadge'; +import type { ScanSummary, ScanDetailTab } from '@/types/security'; + +type FindingsKind = 'secret' | 'misconfig'; + +interface FindingsTabProps { + kind: FindingsKind; + summaries: Record; + loading: boolean; + /** True when the summaries fetch failed; render an error state, never a false "no findings". */ + error?: boolean; + onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; +} + +const COPY: Record = { + secret: { + icon: KeyRound, + detailTab: 'secrets', + countField: 'secret_count', + emptyTitle: 'No secret findings', + emptyBody: 'Trivy found no exposed credentials or keys in the scanned images on this node.', + }, + misconfig: { + icon: FileWarning, + detailTab: 'misconfigs', + countField: 'misconfig_count', + emptyTitle: 'No Compose risks found', + emptyBody: 'Scan a stack from Resources to surface misconfigurations like privileged containers, host mounts, or missing healthchecks.', + intro: 'Compose risks are misconfigurations in your stack definitions, such as privileged containers, Docker socket mounts, host networking, broad bind mounts, or missing healthchecks. Open a result for the specific findings and how to fix them; the Policy packs tab explains each category.', + }, +}; + +/** Index of images/stacks that carry findings of the given kind. Rows open the + * existing scan sheet on the matching detail tab. */ +export function FindingsTab({ kind, summaries, loading, error, onInspect }: FindingsTabProps) { + const copy = COPY[kind]; + const Icon = copy.icon; + const rows = useMemo( + () => + Object.values(summaries) + // Both kinds filter on the kind's count; misconfig additionally requires a + // stack/config scan (image_ref `stack:`). + .filter((s) => s[copy.countField] > 0 && (kind !== 'misconfig' || s.image_ref.startsWith('stack:'))) + .sort((a, b) => b.scanned_at - a.scanned_at), + [summaries, kind, copy.countField], + ); + + if (error) { + return ( +
+ +

Couldn't load scan results

+

Scan results failed to load for this node. Try again shortly.

+
+ ); + } + + if (loading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {copy.intro &&

{copy.intro}

} + + {rows.length === 0 ? ( +
+ +

{copy.emptyTitle}

+

{copy.emptyBody}

+
+ ) : ( +
+ + + + + + + + + + {rows.map((s) => { + const label = kind === 'misconfig' ? s.image_ref.replace(/^stack:/, '') : s.image_ref; + const count = s[copy.countField]; + return ( + + + + + + ); + })} + +
+ {kind === 'misconfig' ? 'Stack' : 'Image'} + FindingsSeverity
+ + {count} + onInspect(s.scan_id, copy.detailTab)} /> +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/security/ImagesTab.tsx b/frontend/src/components/security/ImagesTab.tsx new file mode 100644 index 00000000..a37c5e3f --- /dev/null +++ b/frontend/src/components/security/ImagesTab.tsx @@ -0,0 +1,90 @@ +import { useMemo } from 'react'; +import { Boxes, AlertTriangle } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SeverityBadge } from '@/components/ui/SeverityBadge'; +import type { ScanSummary, ScanDetailTab } from '@/types/security'; + +interface ImagesTabProps { + summaries: Record; + loading: boolean; + /** True when the summaries fetch failed; render an error state, never a false "clean". */ + error?: boolean; + onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; +} + +/** Latest-scan index for real images (stack/config scans live in Compose risks). */ +export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabProps) { + const images = useMemo( + () => + Object.values(summaries) + .filter((s) => !s.image_ref.startsWith('stack:')) + .sort((a, b) => b.scanned_at - a.scanned_at), + [summaries], + ); + + if (error) { + return ( +
+ +

Couldn't load scan results

+

Scan results failed to load for this node. Try again shortly.

+
+ ); + } + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (images.length === 0) { + return ( +
+ +

No scanned images

+

Scan an image from Resources to see its findings here.

+
+ ); + } + + return ( +
+ + + + + + + + + + {images.map((s) => ( + + + + + + ))} + +
ImageFindingsSeverity
+ + + {s.critical > 0 && {s.critical}C} + {s.high > 0 && {s.high}H} + {s.secret_count > 0 && {s.secret_count} secret} + {s.misconfig_count > 0 && {s.misconfig_count} misconfig} + {s.fixable > 0 && {s.fixable} fixable} + {s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && clean} + + onInspect(s.scan_id, 'vulns')} /> +
+
+ ); +} diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx new file mode 100644 index 00000000..372342e4 --- /dev/null +++ b/frontend/src/components/security/OverviewTab.tsx @@ -0,0 +1,130 @@ +import { ShieldOff } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SignalRail, type SignalTile } from '@/components/ui/SignalRail'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import type { SecurityOverview } from '@/types/security'; +import type { SecurityTab } from '@/lib/events'; + +interface OverviewTabProps { + overview: SecurityOverview | null; + /** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */ + loadError: 'unsupported' | 'failed' | null; + onNavigate: (tab: SecurityTab) => void; +} + +const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = { + value: 'text-stat-value', + warn: 'text-warning', + subtitle: 'text-stat-subtitle', +}; + +function StatusRow({ label, value, tone }: { label: string; value: string; tone?: 'value' | 'warn' | 'subtitle' }) { + const toneClass = STATUS_ROW_TONE[tone ?? 'value']; + return ( +
+ {label} + {value} +
+ ); +} + +export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) { + if (loadError === 'unsupported') { + return ( +
+ +

Overview unavailable on this node

+

+ This node does not report a security overview. Browse images, history, and scanner setup directly. +

+
+ ); + } + + if (loadError === 'failed') { + return ( +
+ +

Couldn't load the overview

+

+ The security overview failed to load for this node. Switch nodes and back, or try again shortly. +

+
+ ); + } + + if (!overview) { + return ( +
+ + +
+ ); + } + + const tiles: SignalTile[] = [ + { kicker: 'Scanned images', value: String(overview.scannedImages) }, + { kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' }, + { kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value' }, + { kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value' }, + { kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value' }, + { kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value' }, + ]; + + const scannerValue = overview.scanner.available + ? `${overview.scanner.source}${overview.scanner.version ? ` · v${overview.scanner.version}` : ''}` + : 'not installed'; + + return ( +
+ {/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail + instead of crushing the fixed columns. */} +
+
+ +
+
+ +
+
+

Scanner

+ + {overview.scanner.source === 'managed' && ( + + )} + + {!overview.scanner.available && ( + + )} +
+ +
+

Deploy enforcement

+ 0 ? 'value' : 'subtitle'} + /> + +

+ Manage enforcement policies on the Policies tab. This is a read-only posture for the active node. +

+
+
+
+ ); +} diff --git a/frontend/src/components/security/PolicyPacksTab.tsx b/frontend/src/components/security/PolicyPacksTab.tsx new file mode 100644 index 00000000..a7b21a43 --- /dev/null +++ b/frontend/src/components/security/PolicyPacksTab.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { apiFetch } from '@/lib/api'; +import type { PolicyPack, PolicyPackRule } from '@/types/security'; + +const SEVERITY_TEXT: Record = { + CRITICAL: 'text-destructive', + HIGH: 'text-warning', + MEDIUM: 'text-warning', + LOW: 'text-muted-foreground', +}; + +function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforcement'] }) { + const enforceable = enforcement === 'enforceable'; + return ( + + {enforceable ? 'Enforceable' : 'Warning'} + + ); +} + +export function PolicyPacksTab() { + const [packs, setPacks] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + // The catalog is global/static, so target the local control regardless + // of which node is active. + const res = await apiFetch('/security/policy-packs', { localOnly: true }); + if (!res.ok) throw new Error('Failed to load policy packs'); + const data = (await res.json()) as PolicyPack[]; + if (!cancelled) setPacks(Array.isArray(data) ? data : []); + } catch (err) { + // The catalog is a static, always-available route, so a failure here is a + // real bug (routing/proxy/auth) worth a breadcrumb, not a silent empty state. + console.error('[Security] Failed to load policy packs:', err); + if (!cancelled) setError(true); + } + })(); + return () => { cancelled = true; }; + }, []); + + if (error) { + return ( +

+ Policy packs could not be loaded. +

+ ); + } + + if (!packs) { + return ( +
+ + +
+ ); + } + + return ( +
+

+ Policy packs are curated security expectations for a deployment posture. Packs are advisory in + Community: they explain what good looks like. Block-on-deploy enforcement is an Admiral capability. +

+ + {packs.map((pack) => ( +
+
+

{pack.name}

+

{pack.tagline}

+

{pack.tierCopy}

+
+
    + {pack.rules.map((rule) => ( +
  • +
    +
    + {rule.name} + + {rule.severity} + +
    + +
    +
    +
    Checks
    +
    {rule.whatItChecks}
    +
    Why
    +
    {rule.why}
    +
    Fix
    +
    {rule.howToFix}
    +
    +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/security/ScanPolicyManager.tsx similarity index 52% rename from frontend/src/components/settings/SecuritySection.tsx rename to frontend/src/components/security/ScanPolicyManager.tsx index 1c83666d..19f34bfd 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -9,16 +9,14 @@ import { Combobox } from '@/components/ui/combobox'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react'; -import { SettingsCallout } from './SettingsCallout'; -import { SettingsPrimaryButton } from './SettingsActions'; -import { useMastheadStats } from './MastheadStatsContext'; -import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; +import { ShieldCheck, Plus, Trash2, Pencil, Info } from 'lucide-react'; +import { SettingsCallout } from '@/components/settings/SettingsCallout'; +import { SettingsPrimaryButton } from '@/components/settings/SettingsActions'; import { useNodes } from '@/context/NodeContext'; -import { useTrivyStatus } from '@/hooks/useTrivyStatus'; -import { SuppressionsPanel } from './SuppressionsPanel'; -import { MisconfigAckPanel } from './MisconfigAckPanel'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; +import { useTrivyStatus } from '@/hooks/useTrivyStatus'; +import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [ { value: 'CRITICAL', label: 'Critical' }, @@ -43,141 +41,70 @@ const EMPTY_FORM: PolicyFormState = { enabled: true, }; -const TRIVY_SOURCE_BADGES: Record<'managed' | 'host' | 'none', { label: string; variant: 'outline' | 'secondary' }> = { - managed: { label: 'Installed (managed)', variant: 'outline' }, - host: { label: 'Installed (host)', variant: 'outline' }, - none: { label: 'Not installed', variant: 'secondary' }, -}; - -const TRIVY_SOURCE_DESCRIPTIONS: Record<'managed' | 'host' | 'none', string | null> = { - managed: null, - host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.', - none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.", -}; - -const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = { - install: { loading: 'Installing Trivy...', success: 'Trivy installed' }, - update: { loading: 'Updating Trivy...', success: 'Trivy updated' }, - uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' }, -}; - -export function SecuritySection({ isPaid }: { isPaid: boolean }) { +/** + * Deploy-enforcement scan policies (block-on-deploy severity thresholds), the + * honor-suppressions toggle, and the replica "managed by control" state. This + * is the paid governance surface for the Security page Policies tab; it returns + * null for Community (no enforcement management) so the catalog is all a + * Community operator sees. Policies are control-governed: fetched localOnly and + * shown only on the local node, mirroring how the rest of the fleet-governance + * UI behaves. + */ +export function ScanPolicyManager() { + const { isPaid } = useLicense(); const { isAdmin } = useAuth(); + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; + const { status: trivy, refresh: refreshTrivy } = useTrivyStatus(); + const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); - - const { activeNode } = useNodes(); - const isRemote = activeNode?.type === 'remote'; - const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); - const [trivyBusy, setTrivyBusy] = useState(null); - const [uninstallConfirm, setUninstallConfirm] = useState(false); + const [honorBusy, setHonorBusy] = useState(false); const [fleetRole, setFleetRole] = useState('control'); const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false); const [demoteConfirm, setDemoteConfirm] = useState(false); const [demoteBusy, setDemoteBusy] = useState(false); const isReplica = fleetRole === 'replica'; - const runTrivyOp = async ( - op: 'install' | 'update' | 'uninstall', - path: string, - method: 'POST' | 'DELETE', - ) => { - const { loading, success } = TRIVY_OP_LABELS[op]; - setTrivyBusy(op); - const toastId = toast.loading(loading); - try { - const res = await apiFetch(path, { method }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.error || `Trivy ${op} failed`); - } - toast.success(success); - await Promise.all([refreshTrivy(), refreshUpdateCheck()]); - } catch (err) { - toast.error((err as Error)?.message || `Trivy ${op} failed`); - } finally { - toast.dismiss(toastId); - setTrivyBusy(null); - } - }; - - const handleInstallTrivy = () => runTrivyOp('install', '/security/trivy-install', 'POST'); - const handleUpdateTrivy = () => runTrivyOp('update', '/security/trivy-update', 'POST'); - const handleUninstallTrivy = async () => { - setUninstallConfirm(false); - await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE'); - }; - - const handleAutoUpdateToggle = async (enabled: boolean) => { - setTrivyBusy('auto-update'); - try { - const res = await apiFetch('/security/trivy-auto-update', { - method: 'PUT', - body: JSON.stringify({ enabled }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.error || 'Failed to update setting'); - } - await refreshTrivy(); - } catch (err) { - toast.error((err as Error)?.message || 'Failed to update setting'); - } finally { - setTrivyBusy(null); - } - }; - - const handleHonorSuppressionsToggle = async (enabled: boolean) => { - setTrivyBusy('honor-suppressions'); - try { - const res = await apiFetch('/security/deploy-block-honor-suppressions', { - method: 'PUT', - body: JSON.stringify({ enabled }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.error || 'Failed to update setting'); - } - await refreshTrivy(); - } catch (err) { - toast.error((err as Error)?.message || 'Failed to update setting'); - } finally { - setTrivyBusy(null); - } - }; - const fetchPolicies = async () => { + setLoadError(false); try { const res = await apiFetch('/security/policies', { localOnly: true }); - if (res.ok) { - const data = await res.json(); - setPolicies(Array.isArray(data) ? data : []); + if (!res.ok) { + // A non-OK response must not read as "no policies configured", which + // would falsely imply nothing is enforcing. + setLoadError(true); + return; } + const data = await res.json(); + setPolicies(Array.isArray(data) ? data : []); } catch (err) { console.error('Failed to load scan policies:', err); toast.error('Failed to load scan policies'); + setLoadError(true); } finally { setLoading(false); } }; useEffect(() => { - if (!isPaid) { setLoading(false); return; } - if (isRemote) { setPolicies([]); setLoading(false); return; } + if (!isPaid || isRemote) { setLoading(false); return; } fetchPolicies(); }, [isPaid, isRemote]); useEffect(() => { + if (!isPaid || isRemote) return; void refreshTrivy(); - }, [activeNode?.id, refreshTrivy]); + }, [isPaid, isRemote, activeNode?.id, refreshTrivy]); useEffect(() => { - if (isRemote) return; + if (!isPaid || isRemote) return; let cancelled = false; (async () => { try { @@ -199,7 +126,26 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { } })(); return () => { cancelled = true; }; - }, [isRemote]); + }, [isPaid, isRemote]); + + const handleHonorSuppressionsToggle = async (enabled: boolean) => { + setHonorBusy(true); + try { + const res = await apiFetch('/security/deploy-block-honor-suppressions', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update setting'); + } + await refreshTrivy(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update setting'); + } finally { + setHonorBusy(false); + } + }; const handleDemote = async () => { setDemoteBusy(true); @@ -297,27 +243,35 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { } }; - useMastheadStats( - loading - ? null - : [ - ...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []), - { - label: 'TRIVY', - value: trivy.source === 'none' ? 'missing' : trivy.source, - tone: trivy.source === 'none' ? 'warn' : 'value' as const, - }, - ], - ); + // Enforcement management is a paid governance surface; Community sees only the + // policy-pack catalog above it. + if (!isPaid) return null; return ( -
- {isPaid && isAdmin && !isRemote && !isReplica && ( -
+
+
+

Deploy enforcement policies

+ {isAdmin && !isRemote && !isReplica && ( Add policy + )} +
+ + {isRemote && ( +
+
)} @@ -364,95 +318,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)} -
-
-
- - Vulnerability Scanner - - {TRIVY_SOURCE_BADGES[trivy.source].label} - - {updateCheck?.updateAvailable && ( - - Update available to v{updateCheck.latest} - - )} -
-
- {isAdmin && trivy.source === 'none' && ( - - {trivyBusy === 'install' ? ( - - ) : ( - - )} - Install Trivy - - )} - {isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && ( - - )} - {isAdmin && trivy.source === 'managed' && ( - - )} -
-
- - {trivy.source === 'managed' && trivy.version && ( -
Version: v{trivy.version}
- )} - {TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && ( -
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}
- )} - - {trivy.source === 'managed' && isAdmin && ( -
-
- -

- Check daily and install newer Trivy releases automatically. -

-
- -
- )} -
- - {isRemote && ( -
-
- )} - {!isRemote && loading && (
@@ -460,7 +325,15 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)} - {isPaid && !isRemote && !loading && policies.length === 0 && ( + {!isRemote && !loading && loadError && ( + } + title="Couldn't load scan policies" + subtitle="Scan policies failed to load. Try again shortly." + /> + )} + + {!isRemote && !loading && !loadError && policies.length === 0 && ( } title="No scan policies configured" @@ -468,7 +341,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { /> )} - {isPaid && !isRemote && !loading && + {!isRemote && !loading && policies.map((policy) => (
@@ -520,7 +393,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
))} - {isPaid && isAdmin && !isRemote && ( + {isAdmin && !isRemote && (
@@ -531,117 +404,95 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)} - {!isRemote && } - - {!isRemote && } - - {isPaid && ( - <> - - + + +
+ + setForm({ ...form, name: e.target.value })} /> - -
- - setForm({ ...form, name: e.target.value })} - /> -
-
- - setForm({ ...form, stack_pattern: e.target.value })} - /> -

- Glob-style pattern matched against stack names. Leave blank to apply to all stacks. -

-
-
- - setForm({ ...form, max_severity: v as VulnSeverity })} - /> -
-
-
- -

- Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert. -

-
- setForm({ ...form, block_on_deploy: c })} - /> -
-
-
- -

Disabled policies are skipped during evaluation.

-
- setForm({ ...form, enabled: c })} - /> -
-
- setDialogOpen(false)}> - Cancel - - } - primary={ - - {saving ? 'Saving...' : editingId ? 'Update' : 'Create'} - - } +
+
+ + setForm({ ...form, stack_pattern: e.target.value })} /> - - - !open && setDeleteId(null)} - variant="destructive" - kicker="SECURITY · DELETE · IRREVERSIBLE" - title="Delete scan policy" - confirmLabel="Delete" - onConfirm={handleDelete} - > -

- Removes the policy immediately. Existing scans are not affected. +

+ Glob-style pattern matched against stack names. Leave blank to apply to all stacks.

-
- - )} +
+
+ + setForm({ ...form, max_severity: v as VulnSeverity })} + /> +
+
+
+ +

+ Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert. +

+
+ setForm({ ...form, block_on_deploy: c })} + /> +
+
+
+ +

Disabled policies are skipped during evaluation.

+
+ setForm({ ...form, enabled: c })} + /> +
+
+ setDialogOpen(false)}> + Cancel + + } + primary={ + + {saving ? 'Saving...' : editingId ? 'Update' : 'Create'} + + } + /> +
!open && setDeleteId(null)} variant="destructive" - kicker="TRIVY · REMOVE · IRREVERSIBLE" - title="Remove Trivy" - confirmLabel="Remove" - onConfirm={handleUninstallTrivy} + kicker="SECURITY · DELETE · IRREVERSIBLE" + title="Delete scan policy" + confirmLabel="Delete" + onConfirm={handleDelete} >

- Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided. + Removes the policy immediately. Existing scans are not affected.

diff --git a/frontend/src/components/security/ScannerSetupTab.tsx b/frontend/src/components/security/ScannerSetupTab.tsx new file mode 100644 index 00000000..ec961e11 --- /dev/null +++ b/frontend/src/components/security/ScannerSetupTab.tsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react'; +import { Info } from 'lucide-react'; +import { useTrivyStatus } from '@/hooks/useTrivyStatus'; +import { useNodes } from '@/context/NodeContext'; +import { TrivyManager } from './TrivyManager'; + +/** Scanner install/update/health for the active node. Owns the single + * useTrivyStatus instance and feeds the controlled TrivyManager. */ +export function ScannerSetupTab() { + const { status, updateCheck, refresh, refreshUpdateCheck } = useTrivyStatus(); + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; + + // useTrivyStatus only refreshes on mount. Re-fetch when the active node + // changes so the displayed scanner state matches the node TrivyManager's + // actions target (both follow x-node-id); otherwise switching nodes while on + // this tab would show node A's status while install/update hit node B. + useEffect(() => { + void refresh(); + }, [activeNode?.id, refresh]); + + return ( +
+

+ Vulnerability scanning uses Trivy, installed independently on each node. Manage the scanner for the + active node here. +

+ + {isRemote && ( +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/security/TrivyManager.tsx b/frontend/src/components/security/TrivyManager.tsx new file mode 100644 index 00000000..00fb532e --- /dev/null +++ b/frontend/src/components/security/TrivyManager.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { TogglePill } from '@/components/ui/toggle-pill'; +import { ConfirmModal } from '@/components/ui/modal'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; +import { ShieldCheck, Download, RefreshCw, Loader2 } from 'lucide-react'; +import { SettingsPrimaryButton } from '@/components/settings/SettingsActions'; +import { useAuth } from '@/context/AuthContext'; +import type { TrivyStatus, TrivyUpdateCheck, TrivySource } from '@/types/security'; + +const TRIVY_SOURCE_BADGES: Record = { + managed: { label: 'Installed (managed)', variant: 'outline' }, + host: { label: 'Installed (host)', variant: 'outline' }, + none: { label: 'Not installed', variant: 'secondary' }, +}; + +const TRIVY_SOURCE_DESCRIPTIONS: Record = { + managed: null, + host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.', + none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.", +}; + +const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = { + install: { loading: 'Installing Trivy...', success: 'Trivy installed' }, + update: { loading: 'Updating Trivy...', success: 'Trivy updated' }, + uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' }, +}; + +interface TrivyManagerProps { + status: TrivyStatus; + updateCheck: TrivyUpdateCheck | null; + refresh: () => Promise; + refreshUpdateCheck: () => Promise; +} + +/** + * Scanner install/update/uninstall/auto-update controls for managed Trivy. + * Controlled: the parent owns the single `useTrivyStatus` instance and passes + * the status plus refresh callbacks, so a host that renders this alongside + * other Trivy-derived UI (the Settings security section) keeps one source of + * truth. Mounted by both the Settings security section and the Security page + * Scanner setup tab. + */ +export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) { + const { isAdmin } = useAuth(); + const [trivyBusy, setTrivyBusy] = useState(null); + const [uninstallConfirm, setUninstallConfirm] = useState(false); + + const runTrivyOp = async ( + op: 'install' | 'update' | 'uninstall', + path: string, + method: 'POST' | 'DELETE', + ) => { + const { loading, success } = TRIVY_OP_LABELS[op]; + setTrivyBusy(op); + const toastId = toast.loading(loading); + try { + const res = await apiFetch(path, { method }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || `Trivy ${op} failed`); + } + toast.success(success); + await Promise.all([refresh(), refreshUpdateCheck()]); + } catch (err) { + toast.error((err as Error)?.message || `Trivy ${op} failed`); + } finally { + toast.dismiss(toastId); + setTrivyBusy(null); + } + }; + + const handleInstall = () => runTrivyOp('install', '/security/trivy-install', 'POST'); + const handleUpdate = () => runTrivyOp('update', '/security/trivy-update', 'POST'); + const handleUninstall = async () => { + setUninstallConfirm(false); + await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE'); + }; + + const handleAutoUpdateToggle = async (enabled: boolean) => { + setTrivyBusy('auto-update'); + try { + const res = await apiFetch('/security/trivy-auto-update', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update setting'); + } + await refresh(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update setting'); + } finally { + setTrivyBusy(null); + } + }; + + return ( + <> +
+
+
+ + Vulnerability Scanner + + {TRIVY_SOURCE_BADGES[status.source].label} + + {updateCheck?.updateAvailable && ( + + Update available to v{updateCheck.latest} + + )} +
+
+ {isAdmin && status.source === 'none' && ( + + {trivyBusy === 'install' ? ( + + ) : ( + + )} + Install Trivy + + )} + {isAdmin && status.source === 'managed' && updateCheck?.updateAvailable && ( + + )} + {isAdmin && status.source === 'managed' && ( + + )} +
+
+ + {status.source === 'managed' && status.version && ( +
Version: v{status.version}
+ )} + {TRIVY_SOURCE_DESCRIPTIONS[status.source] && ( +
{TRIVY_SOURCE_DESCRIPTIONS[status.source]}
+ )} + + {status.source === 'managed' && isAdmin && ( +
+
+ +

+ Check daily and install newer Trivy releases automatically. +

+
+ +
+ )} +
+ + +

+ Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided. +

+
+ + ); +} diff --git a/frontend/src/components/security/__tests__/FindingsTab.test.tsx b/frontend/src/components/security/__tests__/FindingsTab.test.tsx new file mode 100644 index 00000000..8bfb1193 --- /dev/null +++ b/frontend/src/components/security/__tests__/FindingsTab.test.tsx @@ -0,0 +1,63 @@ +/** + * FindingsTab is the shared index for Secrets and Compose risks. It filters the + * lifted image summaries by kind and opens the scan sheet on the matching + * detail tab (so a Secrets row lands on Secrets even when the scan has CVEs). + */ +import { it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FindingsTab } from '../FindingsTab'; +import type { ScanSummary } from '@/types/security'; + +function summary(overrides: Partial & { image_ref: string; scan_id: number }): ScanSummary { + return { + highest_severity: 'HIGH', + scanned_at: 1, + total: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + unknown: 0, + fixable: 0, + secret_count: 0, + misconfig_count: 0, + ...overrides, + }; +} + +it('secret variant lists only images with secrets and opens the Secrets tab', async () => { + const onInspect = vi.fn(); + const summaries = { + 'withsecret:1': summary({ image_ref: 'withsecret:1', scan_id: 10, secret_count: 2 }), + 'clean:1': summary({ image_ref: 'clean:1', scan_id: 11, secret_count: 0 }), + }; + render(); + + expect(screen.getByText('withsecret:1')).toBeInTheDocument(); + expect(screen.queryByText('clean:1')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('withsecret:1')); + expect(onInspect).toHaveBeenCalledWith(10, 'secrets'); +}); + +it('misconfig variant lists only stack scans and opens the Misconfigs tab', async () => { + const onInspect = vi.fn(); + const summaries = { + 'stack:web': summary({ image_ref: 'stack:web', scan_id: 20, misconfig_count: 3 }), + 'nginx:1': summary({ image_ref: 'nginx:1', scan_id: 21, misconfig_count: 0 }), + }; + render(); + + // Stack name is shown without the "stack:" prefix. + expect(screen.getByText('web')).toBeInTheDocument(); + expect(screen.queryByText('nginx:1')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('web')); + expect(onInspect).toHaveBeenCalledWith(20, 'misconfigs'); +}); + +it('shows an empty state when there are no findings of the kind', () => { + render(); + expect(screen.getByText('No secret findings')).toBeInTheDocument(); +}); diff --git a/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx b/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx new file mode 100644 index 00000000..4ed1ab83 --- /dev/null +++ b/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx @@ -0,0 +1,61 @@ +/** + * PolicyPacksTab renders the static catalog and, crucially, fetches it with + * { localOnly: true } so the global catalog is available regardless of which + * node is active. + */ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); + +import { apiFetch } from '@/lib/api'; +import { PolicyPacksTab } from '../PolicyPacksTab'; +import type { PolicyPack } from '@/types/security'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +const PACKS: PolicyPack[] = [ + { + id: 'homelab-baseline', + name: 'Homelab baseline', + tagline: 'Gentle defaults.', + tierCopy: 'Advisory.', + rules: [ + { id: 'pin-image-tag', name: 'Pin image tags', severity: 'LOW', whatItChecks: 'tags', why: 'reproducible', howToFix: 'pin', enforcement: 'warning' }, + ], + }, + { + id: 'strict-production', + name: 'Strict production', + tagline: 'Zero tolerance.', + tierCopy: 'Strict.', + rules: [ + { id: 'no-privileged', name: 'No privileged containers', severity: 'CRITICAL', whatItChecks: 'priv', why: 'escape', howToFix: 'drop', enforcement: 'enforceable' }, + ], + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockedFetch.mockResolvedValue(jsonResponse(200, PACKS)); +}); + +it('fetches the catalog with localOnly and renders packs and rules', async () => { + render(); + await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument()); + expect(screen.getByText('Strict production')).toBeInTheDocument(); + expect(screen.getByText('Pin image tags')).toBeInTheDocument(); + expect(screen.getByText('No privileged containers')).toBeInTheDocument(); + + expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true }); +}); + +it('labels rules as warning or enforceable', async () => { + render(); + await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument()); + expect(screen.getByText('Enforceable')).toBeInTheDocument(); +}); diff --git a/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx new file mode 100644 index 00000000..15fcba09 --- /dev/null +++ b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx @@ -0,0 +1,72 @@ +/** + * ScanPolicyManager is the paid deploy-enforcement surface on the Security + * Policies tab. Key guards: it renders nothing for Community, and a failed + * policy fetch surfaces an error state instead of a false "No scan policies + * configured". + */ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/context/LicenseContext'); +vi.mock('@/context/AuthContext'); +vi.mock('@/context/NodeContext'); +vi.mock('@/hooks/useTrivyStatus'); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() }, +})); + +import { apiFetch } from '@/lib/api'; +import * as LicenseContext from '@/context/LicenseContext'; +import * as AuthContext from '@/context/AuthContext'; +import * as NodeContext from '@/context/NodeContext'; +import * as TrivyStatus from '@/hooks/useTrivyStatus'; +import { ScanPolicyManager } from '../ScanPolicyManager'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +function setup({ isPaid }: { isPaid: boolean }) { + vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid } as unknown as ReturnType); + vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType); + vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType); + vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({ + status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, busy: false }, + updateCheck: null, + refresh: vi.fn().mockResolvedValue(undefined), + refreshUpdateCheck: vi.fn().mockResolvedValue(undefined), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Fleet-role probe resolves to control by default; per-test override for policies. + mockedFetch.mockImplementation((url: string) => + Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [])), + ); +}); + +it('renders nothing for a Community operator (paid surface)', () => { + setup({ isPaid: false }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it('surfaces an error state when the policies fetch fails (no false "no policies")', async () => { + setup({ isPaid: true }); + mockedFetch.mockImplementation((url: string) => + Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(500, {})), + ); + render(); + await waitFor(() => expect(screen.getByText("Couldn't load scan policies")).toBeInTheDocument()); + expect(screen.queryByText('No scan policies configured')).not.toBeInTheDocument(); +}); + +it('shows the empty state when there are genuinely no policies', async () => { + setup({ isPaid: true }); + render(); + await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument()); +}); diff --git a/frontend/src/components/security/__tests__/deriveMasthead.test.ts b/frontend/src/components/security/__tests__/deriveMasthead.test.ts new file mode 100644 index 00000000..aeae6ef5 --- /dev/null +++ b/frontend/src/components/security/__tests__/deriveMasthead.test.ts @@ -0,0 +1,41 @@ +/** + * The Security masthead state word is the headline posture signal an operator + * reads first, so its derivation is locked here. Critical must beat High. + */ +import { it, expect } from 'vitest'; +import { deriveMasthead } from '../securityMasthead'; +import type { SecurityOverview } from '@/types/security'; + +function overview(o: Partial): SecurityOverview { + return { + scannedImages: 0, + critical: 0, + high: 0, + fixable: 0, + secrets: 0, + misconfigs: 0, + staleScans: 0, + failedScans: 0, + lastSuccessfulScanAt: null, + scanner: { available: true, version: '1', source: 'managed', autoUpdate: false }, + deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 }, + ...o, + }; +} + +it('reads Unknown/idle when there is no overview or a load error', () => { + expect(deriveMasthead(null, false)).toEqual({ state: 'Unknown', tone: 'idle' }); + expect(deriveMasthead(overview({ critical: 5 }), true)).toEqual({ state: 'Unknown', tone: 'idle' }); +}); + +it('reads Critical/error when any critical finding exists (critical wins over high)', () => { + expect(deriveMasthead(overview({ critical: 1, high: 9 }), false)).toEqual({ state: 'Critical', tone: 'error' }); +}); + +it('reads At risk/warn when there are highs but no criticals', () => { + expect(deriveMasthead(overview({ critical: 0, high: 2 }), false)).toEqual({ state: 'At risk', tone: 'warn' }); +}); + +it('reads Secure/live when there are no critical or high findings', () => { + expect(deriveMasthead(overview({ critical: 0, high: 0 }), false)).toEqual({ state: 'Secure', tone: 'live' }); +}); diff --git a/frontend/src/components/security/securityMasthead.ts b/frontend/src/components/security/securityMasthead.ts new file mode 100644 index 00000000..270b0272 --- /dev/null +++ b/frontend/src/components/security/securityMasthead.ts @@ -0,0 +1,16 @@ +import type { MastheadTone } from '@/components/ui/PageMasthead'; +import type { SecurityOverview } from '@/types/security'; + +/** + * Derives the Security page masthead state word and tone from the overview. + * Critical outranks High; an absent overview or a load error reads as Unknown. + */ +export function deriveMasthead( + overview: SecurityOverview | null, + error: boolean, +): { state: string; tone: MastheadTone } { + if (error || !overview) return { state: 'Unknown', tone: 'idle' }; + if (overview.critical > 0) return { state: 'Critical', tone: 'error' }; + if (overview.high > 0) return { state: 'At risk', tone: 'warn' }; + return { state: 'Secure', tone: 'live' }; +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index b04609c6..50576ce7 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -194,7 +194,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 77cc7ca2..5b587c81 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -35,9 +35,6 @@ const UsersSection = lazy(() => const WebhooksSection = lazy(() => import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })), ); -const SecuritySection = lazy(() => - import('./SecuritySection').then(m => ({ default: m.SecuritySection })), -); const LabelsSection = lazy(() => import('./LabelsSection').then(m => ({ default: m.LabelsSection })), ); @@ -71,7 +68,6 @@ function SectionSkeleton() { function renderSection( sectionId: SectionId, - isPaid: boolean, onDirtyChange: (section: SectionId, dirty: boolean) => void, ) { switch (sectionId) { @@ -89,7 +85,6 @@ function renderSection( case 'notifications': return ; case 'notification-routing': return ; case 'webhooks': return ; - case 'security': return ; case 'cloud-backup': return ; case 'developer': return onDirtyChange('developer', d)} />; case 'data-retention': return onDirtyChange('data-retention', d)} />; @@ -105,7 +100,6 @@ function renderSection( interface SettingsSectionContentProps { sectionId: SectionId; - isPaid: boolean; onDirtyChange: (section: SectionId, dirty: boolean) => void; /** Render the section's lead description paragraph above the content. */ showDescription?: boolean; @@ -117,14 +111,14 @@ interface SettingsSectionContentProps { * the desktop SettingsPage and the mobile settings screen so the section switch, * lazy splitting, and gating live in exactly one place. */ -export function SettingsSectionContent({ sectionId, isPaid, onDirtyChange, showDescription }: SettingsSectionContentProps) { +export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) { const item = getSettingsItem(sectionId); // Memoize the section element so unrelated re-renders of the host page (the // command palette opening, a dirty-flag toggle) do not re-render the active // section. onDirtyChange is stable from both call sites. const element = useMemo( - () => renderSection(sectionId, isPaid, onDirtyChange), - [sectionId, isPaid, onDirtyChange], + () => renderSection(sectionId, onDirtyChange), + [sectionId, onDirtyChange], ); return ( <> diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index aebc09d4..bb6ea667 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -63,7 +63,10 @@ describe('settings registry', () => { const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i])); expect(byId.get('notifications')?.label).toBe('Channels'); expect(byId.get('notification-routing')?.label).toBe('Notification Routing'); - expect(byId.get('security')?.label).toBe('Vulnerability Scanning'); + }); + + it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => { + expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'security')).toBe(false); }); it('opens Registries to Community while keeping it admin-only', () => { diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 82679fb1..f2031b2c 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -14,7 +14,7 @@ export { SupportSection } from './SupportSection'; export { AboutSection } from './AboutSection'; export { RecoverySection } from './RecoverySection'; -// Paid-tier sections (UsersSection, WebhooksSection, SecuritySection, +// Paid-tier sections (UsersSection, WebhooksSection, // LabelsSection, CloudBackupSection, NotificationRoutingSection) are NOT // re-exported from this barrel. They are dynamically imported with // React.lazy in SettingsPage.tsx so their JSX, copy, and prop shapes do not diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index b2322625..bb9d13ff 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -8,7 +8,6 @@ export type SettingsGroupId = | 'notifications' | 'automation' | 'organization' - | 'security' | 'operations' | 'help'; @@ -27,7 +26,6 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [ { id: 'notifications', label: 'Notifications', glyph: '\u25C7' }, { id: 'automation', label: 'Automation', glyph: '\u25C7' }, { id: 'organization', label: 'Organization', glyph: '\u25C7' }, - { id: 'security', label: 'Security', glyph: '\u25C6' }, { id: 'operations', label: 'Operations', glyph: '\u25C7' }, { id: 'help', label: 'Help', glyph: '\u25C7' }, ]; @@ -225,17 +223,6 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ tier: null, scope: 'node', }, - // Security - { - id: 'security', - group: 'security', - label: 'Vulnerability Scanning', - description: 'Image scanning, suppressions, and posture defaults.', - keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening', 'vulnerability', 'misconfig'], - tier: null, - scope: 'node', - adminOnly: true, - }, // Operations { id: 'data-retention', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index d2f1b1d8..e02e4d86 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -54,7 +54,6 @@ export type SectionId = | 'fleet-mesh' | 'notifications' | 'webhooks' - | 'security' | 'cloud-backup' | 'developer' | 'data-retention' diff --git a/frontend/src/components/ui/SeverityBadge.tsx b/frontend/src/components/ui/SeverityBadge.tsx new file mode 100644 index 00000000..ccc5317a --- /dev/null +++ b/frontend/src/components/ui/SeverityBadge.tsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from 'react'; +import { cn } from '@/lib/utils'; +import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; +import { SEVERITY_BADGE_CLASSES, SEVERITY_DOT_CLASSES } from '@/lib/severityStyles'; +import type { ScanSummary, VulnSeverity } from '@/types/security'; + +/** + * Severity pill for a scanned image's latest summary. Shows the highest + * severity (or "Clean") with a state dot and a cursor-follow tooltip carrying + * the last-scanned time and a severity breakdown. Shared by the Resources view + * and the Security page so the badge stays identical everywhere. + */ +export function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) { + // highest_severity is derived from vulnerabilities only, so a scan with + // secrets or misconfigurations but zero CVEs would otherwise read "Clean". + // Treat those as a non-clean "Findings" state. + const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0; + const key: VulnSeverity | 'CLEAN' | 'FINDINGS' = + summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN'); + const label = key === 'CLEAN' ? 'Clean' : key === 'FINDINGS' ? 'Findings' : key; + const [relative, setRelative] = useState(''); + useEffect(() => { + const compute = () => { + const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000); + setRelative( + scanAge < 1 ? 'just now' + : scanAge < 60 ? `${scanAge}m ago` + : scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago` + : `${Math.round(scanAge / 1440)}d ago`, + ); + }; + compute(); + const id = setInterval(compute, 60000); + return () => clearInterval(id); + }, [summary.scanned_at]); + + return ( + + + + + +
+ + +
+
+
Last scanned
+
{relative}
+ {summary.total > 0 && ( +
+ {summary.critical > 0 && {summary.critical}C} + {summary.high > 0 && {summary.high}H} + {summary.medium > 0 && {summary.medium}M} + {summary.low > 0 && {summary.low}L} +
+ )} + {summary.total === 0 && hasNonVulnFindings && ( +
+ {(summary.secret_count ?? 0) > 0 && {summary.secret_count} secret} + {(summary.misconfig_count ?? 0) > 0 && {summary.misconfig_count} misconfig} +
+ )} + {summary.total === 0 && !hasNonVulnFindings && ( +
No findings
+ )} +
+
+
+ + ); +} diff --git a/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx b/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx new file mode 100644 index 00000000..269abc0b --- /dev/null +++ b/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx @@ -0,0 +1,53 @@ +/** + * The severity badge was extracted from ResourcesView into a shared component so + * Resources and the Security page render an identical pill. Lock its label + * mapping (highest severity, or "Clean" when there are no findings). + */ +import { it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SeverityBadge } from '../SeverityBadge'; +import type { ScanSummary } from '@/types/security'; + +function summary(overrides: Partial): ScanSummary { + return { + image_ref: 'nginx:1', + highest_severity: 'CRITICAL', + 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, + ...overrides, + }; +} + +it('renders the highest severity and fires onClick', async () => { + const onClick = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /CRITICAL/ }); + await userEvent.click(btn); + expect(onClick).toHaveBeenCalledOnce(); +}); + +it('renders "Clean" when there are no findings of any kind', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /Clean/ })).toBeInTheDocument(); +}); + +it('renders "Findings" (not "Clean") for a secret-only scan with no CVE severity', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Clean/ })).not.toBeInTheDocument(); +}); + +it('renders "Findings" for a misconfig-only scan', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument(); +}); diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts index 5aa0b766..54908a61 100644 --- a/frontend/src/lib/events.ts +++ b/frontend/src/lib/events.ts @@ -22,3 +22,15 @@ export interface SenchoOpenStackDetail { nodeId: number; stackName: string; } + +/** Tabs of the top-level Security view. Used by the nav state and by + * cross-component navigate events that deep-link into a specific tab. */ +export type SecurityTab = + | 'overview' + | 'images' + | 'compose' + | 'secrets' + | 'policies' + | 'suppressions' + | 'history' + | 'scanner'; diff --git a/frontend/src/lib/severityStyles.ts b/frontend/src/lib/severityStyles.ts index 8a97a695..4206f031 100644 --- a/frontend/src/lib/severityStyles.ts +++ b/frontend/src/lib/severityStyles.ts @@ -7,3 +7,30 @@ export const SEVERITY_ROW_TINT: Record = { LOW: 'border-l-[3px] border-transparent', UNKNOWN: 'border-l-[3px] border-transparent', }; + +/** + * Border/background/text classes for a severity pill. `CLEAN` is the all-zero + * state; `FINDINGS` is the amber "not clean but no vulnerability severity" + * state for a scan that has secrets or misconfigurations but zero CVEs (the + * stored highest_severity is derived from vulnerabilities only). + */ +export const SEVERITY_BADGE_CLASSES: Record = { + CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive', + HIGH: 'border-warning/25 bg-warning/8 text-warning', + MEDIUM: 'border-warning/25 bg-warning/8 text-warning', + LOW: 'border-border bg-muted/30 text-muted-foreground', + UNKNOWN: 'border-border bg-muted/20 text-muted-foreground', + CLEAN: 'border-success/25 bg-success/8 text-success', + FINDINGS: 'border-warning/25 bg-warning/8 text-warning', +}; + +/** Leading state-dot color for a severity pill. */ +export const SEVERITY_DOT_CLASSES: Record = { + CRITICAL: 'bg-destructive', + HIGH: 'bg-warning', + MEDIUM: 'bg-warning', + LOW: 'bg-muted-foreground/60', + UNKNOWN: 'bg-muted-foreground/40', + CLEAN: 'bg-success', + FINDINGS: 'bg-warning', +}; diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 8767c688..78bf8429 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -136,6 +136,8 @@ export interface ScanSummary { low: number; unknown: number; fixable: number; + secret_count: number; + misconfig_count: number; } export interface ScanPolicy { @@ -175,3 +177,50 @@ export interface ScanCompareResult { truncated?: boolean; row_limit?: number; } + +/** Node-scoped security posture rollup for the Security page Overview. */ +export interface SecurityOverview { + 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: TrivySource; + autoUpdate: boolean; + }; + deployEnforcement: { + honorSuppressionsOnDeploy: boolean; + /** Approximate count of enabled block-on-deploy policies eligible for this node. */ + eligibleBlockPolicies: number; + }; +} + +/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */ +export type ScanDetailTab = 'vulns' | 'secrets' | 'misconfigs'; + +export type PolicyRuleEnforcement = 'warning' | 'enforceable'; + +export interface PolicyPackRule { + id: string; + name: string; + severity: Exclude; + whatItChecks: string; + why: string; + howToFix: string; + enforcement: PolicyRuleEnforcement; +} + +export interface PolicyPack { + id: string; + name: string; + tagline: string; + tierCopy: string; + rules: PolicyPackRule[]; +}