feat: add dedicated Security page and policy-pack foundation (#1362)

* feat: add dedicated Security page and policy-pack foundation

Bring vulnerability scanning, scan history, suppressions, Compose risks,
secrets, policy packs, and scanner setup into one node-scoped Security
command center instead of scattering them across Resources and Settings.

- New top-level Security view with Overview, Images, Compose risks,
  Secrets, Policies, Suppressions, History, and Scanner setup tabs
  (status masthead + signal rail; controlled tabs with deep-link support).
- Backend: GET /security/overview rollup and GET /security/policy-packs
  static catalog (auth-only, Community). DatabaseService gains an uncapped
  scan-status count and a node-eligible block-policy count, and
  getImageScanSummaries now projects secret and misconfig counts.
- Reuse existing surfaces: the scan-history sheet, the control-governed
  suppression and acknowledgement panels, and the scan-detail sheet (now
  with an initial-tab prop so it opens on the matching finding type).
- Extract a shared SeverityBadge (from Resources) and a TrivyManager
  (from Settings) so both surfaces render identical controls.
- Resources "Scan history" now links into the Security page History tab.
- Docs for the new Security surface and tests for the new endpoints,
  helpers, nav wiring, and tabs.

* refactor: consolidate scanner and policy management onto the Security page

Remove the Settings "Vulnerability Scanning" section now that the Security
page covers the same ground, with every option preserved:

- Scanner install / update / uninstall / auto-update live on the Scanner setup
  tab (TrivyManager).
- Scan policies, the honor-suppressions toggle, and the replica
  managed-by-control / demote controls move into a new ScanPolicyManager on the
  Policies tab (paid; Community sees only the policy-pack catalog).
- CVE suppressions and acknowledgements remain on the Suppressions tab.

Wiring removed: the registry section and the now-empty Security settings group,
the SectionId, the SettingsSectionContent case and the isPaid prop it was the
sole consumer of, and SecuritySection itself. The dashboard configuration-status
"Vulnerability scanning" row now navigates to the Security page Policies tab.

Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept
to the relevant Security page tabs.

* fix: harden Security page scanner refresh, policy-load errors, and secret-only badges

Address independent-review findings on the Security page:

- Scanner setup now refreshes Trivy state when the active node changes, so the
  displayed scanner status matches the node TrivyManager's actions target (both
  follow x-node-id). Previously, switching nodes on the tab left stale state.
- ScanPolicyManager surfaces an explicit error state on a failed policy fetch
  instead of falling through to a false "No scan policies configured".
- The shared SeverityBadge and the Images findings column no longer label a scan
  "clean" when it has secrets or misconfigurations but no CVE severity
  (highest_severity is derived from vulnerabilities only); they show a "Findings"
  state and the secret/misconfig counts instead.
- The Overview enforcement note points to the Policies tab, not the removed
  Settings section.
- The History tab auto-opens the scan-history sheet only on a deep-link (mount
  with the History tab active), not on every manual tab selection.

Adds tests for the badge secret/misconfig state and the policy-load error state.
This commit is contained in:
Anso
2026-06-12 10:41:39 -04:00
committed by GitHub
parent 77f1611971
commit 2a4955f56d
51 changed files with 2559 additions and 509 deletions
@@ -0,0 +1,118 @@
/**
* Unit coverage for the two DatabaseService helpers added for the Security
* overview:
* - countScansByStatus: an UNCAPPED count (getVulnerabilityScans applies a
* per-image history cap that would undercount failed scans).
* - countEligibleBlockPolicies: counts enabled block-on-deploy policies that
* apply to a node (fleet-wide or this node), built on getScanPoliciesForUi
* so a replica never counts a sibling-identity policy.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { ScanPolicy } from '../services/DatabaseService';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
function db() {
return DatabaseService.getInstance();
}
function reset(): void {
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM vulnerability_scans').run();
raw.prepare('DELETE FROM scan_policies').run();
}
function seedFailed(imageRef: string): void {
db().createVulnerabilityScan({
node_id: 1,
image_ref: imageRef,
image_digest: `sha256:${imageRef}-${Math.random().toString(16).slice(2)}`,
scanned_at: 1,
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'failed',
error: 'boom',
stack_context: null,
});
}
function seedPolicy(overrides: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>): void {
db().createScanPolicy({
name: overrides.name ?? 'p',
node_id: overrides.node_id ?? null,
node_identity: overrides.node_identity ?? '',
stack_pattern: overrides.stack_pattern ?? null,
max_severity: overrides.max_severity ?? 'CRITICAL',
block_on_deploy: overrides.block_on_deploy ?? 1,
enabled: overrides.enabled ?? 1,
replicated_from_control: overrides.replicated_from_control ?? 0,
});
}
beforeEach(() => reset());
describe('countScansByStatus', () => {
it('counts failed scans uncapped, even beyond the per-image history cap', () => {
// The grouped history view caps rows per image_ref (default 50). All 55 of
// these are the same image, so a capped path would undercount.
for (let i = 0; i < 55; i++) seedFailed('same-image:1');
expect(db().countScansByStatus(1, 'failed')).toBe(55);
});
it('is node-scoped', () => {
seedFailed('a:1');
db().createVulnerabilityScan({
node_id: 2, image_ref: 'b:1', image_digest: 'sha256:b', scanned_at: 1,
total_vulnerabilities: 0, critical_count: 0, high_count: 0, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: null, os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'failed', error: 'x', stack_context: null,
});
expect(db().countScansByStatus(1, 'failed')).toBe(1);
});
});
describe('countEligibleBlockPolicies (control)', () => {
it('counts fleet-wide and this-node block policies, excludes other nodes / disabled / non-blocking', () => {
seedPolicy({ name: 'fleet-wide', node_id: null }); // counted
seedPolicy({ name: 'this-node', node_id: 1 }); // counted
seedPolicy({ name: 'other-node', node_id: 2 }); // excluded (different node)
seedPolicy({ name: 'disabled', node_id: 1, enabled: 0 }); // excluded (disabled)
seedPolicy({ name: 'no-block', node_id: 1, block_on_deploy: 0 }); // excluded (not blocking)
expect(db().countEligibleBlockPolicies(1, 'control', '')).toBe(2);
});
});
describe('countEligibleBlockPolicies (replica)', () => {
it('filters a replicated policy scoped to a sibling identity, keeps fleet-wide', () => {
// Fleet-wide replicated row (empty identity) applies on every replica.
seedPolicy({ name: 'fleet-wide', node_id: null, replicated_from_control: 1, node_identity: '' });
// Sibling-scoped replicated row must not be counted on this replica.
seedPolicy({ name: 'sibling', node_id: null, replicated_from_control: 1, node_identity: 'sibling-id' });
expect(db().countEligibleBlockPolicies(1, 'replica', 'self-id')).toBe(1);
});
});
@@ -0,0 +1,184 @@
/**
* GET /api/security/overview -> node-scoped posture rollup (Community, auth-only)
* GET /api/security/policy-packs -> static catalog (Community, auth-only, identical per tier)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let viewerCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let TrivyService: typeof import('../services/TrivyService').default;
const DAY = 24 * 60 * 60 * 1000;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
TrivyService = (await import('../services/TrivyService')).default;
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
// Deterministic scanner readout for the overview's scanner block.
const svc = TrivyService.getInstance();
vi.spyOn(svc, 'isTrivyAvailable').mockReturnValue(true);
vi.spyOn(svc, 'getVersion').mockReturnValue('0.52.0');
vi.spyOn(svc, 'getSource').mockReturnValue('managed');
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('ovviewer1', 1);
DatabaseService.getInstance().addUser({ username: 'ov-viewer', password_hash: viewerHash, role: 'viewer' });
const res = await request(app).post('/api/auth/login').send({ username: 'ov-viewer', password: 'ovviewer1' });
const cookies = res.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
afterAll(() => cleanupTestDb(tmpDir));
function db() {
return DatabaseService.getInstance();
}
function seedScan(o: {
node_id?: number;
image_ref: string;
scanned_at: number;
status?: 'completed' | 'failed';
critical?: number;
high?: number;
fixable?: number;
secret?: number;
misconfig?: number;
}): void {
db().createVulnerabilityScan({
node_id: o.node_id ?? 1,
image_ref: o.image_ref,
image_digest: `sha256:${o.image_ref}-${Math.random().toString(16).slice(2)}`,
scanned_at: o.scanned_at,
total_vulnerabilities: (o.critical ?? 0) + (o.high ?? 0),
critical_count: o.critical ?? 0,
high_count: o.high ?? 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: o.fixable ?? 0,
secret_count: o.secret ?? 0,
misconfig_count: o.misconfig ?? 0,
scanners_used: 'vuln',
highest_severity: (o.critical ?? 0) > 0 ? 'CRITICAL' : null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: o.status ?? 'completed',
error: o.status === 'failed' ? 'boom' : null,
stack_context: o.image_ref.startsWith('stack:') ? o.image_ref.slice(6) : null,
});
}
function resetSecurity(): void {
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
raw.prepare('DELETE FROM vulnerability_scans').run();
raw.prepare('DELETE FROM scan_policies').run();
}
describe('GET /api/security/overview', () => {
beforeEach(() => {
resetSecurity();
db().updateGlobalSetting('deploy_block_honor_suppressions', '1');
});
it('aggregates node-scoped counts with the documented shape', async () => {
const now = Date.now();
seedScan({ image_ref: 'imgA:1', scanned_at: now - 1000, critical: 2, high: 1, fixable: 3, secret: 1 });
seedScan({ image_ref: 'imgB:1', scanned_at: now - 8 * DAY }); // stale
seedScan({ image_ref: 'stack:web', scanned_at: now - 2000, misconfig: 2 });
// Failed scans (same image) beyond a single row prove the uncapped count.
for (let i = 0; i < 4; i++) seedScan({ image_ref: 'imgA:1', scanned_at: now, status: 'failed' });
// Other node's data must be excluded.
seedScan({ node_id: 2, image_ref: 'other:1', scanned_at: now, critical: 99 });
// One fleet-wide and one this-node block policy count; an other-node one does not.
db().createScanPolicy({ name: 'fw', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
db().createScanPolicy({ name: 'n1', node_id: 1, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
db().createScanPolicy({ name: 'n2', node_id: 2, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
scannedImages: 2, // imgA + imgB, stack:web excluded
critical: 2, // node-2's 99 excluded
high: 1,
fixable: 3,
secrets: 1,
misconfigs: 2,
staleScans: 1, // imgB only
failedScans: 4, // uncapped
});
expect(res.body.lastSuccessfulScanAt).toBeGreaterThan(0);
expect(res.body.scanner).toMatchObject({ available: true, source: 'managed', version: '0.52.0' });
expect(res.body.deployEnforcement).toMatchObject({
honorSuppressionsOnDeploy: true,
eligibleBlockPolicies: 2,
});
});
it('is reachable by a Community viewer (read-only, auth-only)', async () => {
const res = await request(app).get('/api/security/overview').set('Cookie', viewerCookie);
expect(res.status).toBe(200);
});
it('requires authentication', async () => {
const res = await request(app).get('/api/security/overview');
expect(res.status).toBe(401);
});
});
describe('GET /api/security/policy-packs', () => {
it('returns the 5 default packs with fully-formed rules (auth-only)', async () => {
const res = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body).toHaveLength(5);
for (const pack of res.body) {
expect(pack).toMatchObject({
id: expect.any(String),
name: expect.any(String),
tagline: expect.any(String),
tierCopy: expect.any(String),
});
expect(Array.isArray(pack.rules)).toBe(true);
expect(pack.rules.length).toBeGreaterThan(0);
for (const rule of pack.rules) {
expect(rule).toMatchObject({
id: expect.any(String),
name: expect.any(String),
severity: expect.stringMatching(/^(CRITICAL|HIGH|MEDIUM|LOW)$/),
whatItChecks: expect.any(String),
why: expect.any(String),
howToFix: expect.any(String),
enforcement: expect.stringMatching(/^(warning|enforceable)$/),
});
}
}
});
it('returns 401 unauthenticated', async () => {
const res = await request(app).get('/api/security/policy-packs');
expect(res.status).toBe(401);
});
it('returns an identical catalog regardless of tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const community = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const paid = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
expect(paid.body).toEqual(community.body);
});
});
+100
View File
@@ -17,6 +17,7 @@ import { isDebugEnabled } from '../utils/debug';
import { blockIfReplica } from '../middleware/fleetSyncGuards';
import { validateStackPatternForRedos } from './fleet';
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
// Trivy emits misconfig rule ids in two shapes that Sencho persists verbatim:
@@ -113,6 +114,28 @@ function shapeScanForResponse(scan: VulnerabilityScan): Omit<VulnerabilityScan,
return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) };
}
// A completed scan whose latest run is older than this is considered "stale" in
// the Security overview. Named so the route and its tests share one value.
export const STALE_SCAN_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Shape of the /overview response. Mirrors the frontend `SecurityOverview` type
// (frontend/src/types/security.ts); annotating the response below makes a
// renamed or dropped field a compile error here instead of an undefined read in
// the UI. Keep the two in sync.
interface SecurityOverviewResponse {
scannedImages: number;
critical: number;
high: number;
fixable: number;
secrets: number;
misconfigs: number;
staleScans: number;
failedScans: number;
lastSuccessfulScanAt: number | null;
scanner: { available: boolean; version: string | null; source: 'managed' | 'host' | 'none'; autoUpdate: boolean };
deployEnforcement: { honorSuppressionsOnDeploy: boolean; eligibleBlockPolicies: number };
}
export const securityRouter = Router();
securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Response) => {
@@ -437,6 +460,83 @@ securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Respo
}
});
// Node-scoped security posture rollup for the Security page Overview. Read-only,
// auth-only (Community). Counts derive from the latest-completed-scan-per-image
// summaries plus two precise helpers; the deploy-enforcement block is this
// node's read-only posture, not policy management.
securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): void => {
try {
const db = DatabaseService.getInstance();
const summaries = Object.values(db.getImageScanSummaries(req.nodeId));
const settings = db.getGlobalSettings();
const svc = TrivyService.getInstance();
const now = Date.now();
let scannedImages = 0;
let critical = 0;
let high = 0;
let fixable = 0;
let secrets = 0;
let misconfigs = 0;
let staleScans = 0;
let lastSuccessfulScanAt: number | null = null;
for (const s of summaries) {
// Severity, secret, and misconfig totals are summed across every summary
// (real images and stack/config scans alike). Only scannedImages excludes
// the stack/config rows (stored under a "stack:" image_ref), since those
// are stacks, not images.
if (!s.image_ref.startsWith('stack:')) scannedImages += 1;
critical += s.critical;
high += s.high;
fixable += s.fixable;
secrets += s.secret_count;
misconfigs += s.misconfig_count;
if (now - s.scanned_at > STALE_SCAN_THRESHOLD_MS) staleScans += 1;
if (lastSuccessfulScanAt === null || s.scanned_at > lastSuccessfulScanAt) {
lastSuccessfulScanAt = s.scanned_at;
}
}
const overview: SecurityOverviewResponse = {
scannedImages,
critical,
high,
fixable,
secrets,
misconfigs,
staleScans,
failedScans: db.countScansByStatus(req.nodeId, 'failed'),
lastSuccessfulScanAt,
scanner: {
available: svc.isTrivyAvailable(),
version: svc.getVersion(),
source: svc.getSource(),
autoUpdate: settings.trivy_auto_update === '1',
},
deployEnforcement: {
honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1',
eligibleBlockPolicies: db.countEligibleBlockPolicies(
req.nodeId,
FleetSyncService.getRole(),
FleetSyncService.getSelfIdentity(),
),
},
};
res.json(overview);
} catch (error) {
console.error('[Security] Failed to build overview:', error);
res.status(500).json({ error: 'Failed to build security overview' });
}
});
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
// enforcement. The frontend fetches this with localOnly so the global catalog
// is available regardless of which node is active.
securityRouter.get('/policy-packs', authMiddleware, (_req: Request, res: Response): void => {
res.json(DEFAULT_POLICY_PACKS);
});
securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
+44 -1
View File
@@ -703,6 +703,8 @@ export interface ScanSummary {
low: number;
unknown: number;
fixable: number;
secret_count: number;
misconfig_count: number;
scanned_at: number;
scan_id: number;
}
@@ -4451,7 +4453,7 @@ export class DatabaseService {
.prepare(
`SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
vs.unknown_count, vs.fixable_count, vs.scanned_at
vs.unknown_count, vs.fixable_count, vs.secret_count, vs.misconfig_count, vs.scanned_at
FROM vulnerability_scans vs
INNER JOIN (
SELECT image_ref, MAX(scanned_at) AS max_scanned
@@ -4472,6 +4474,8 @@ export class DatabaseService {
low_count: number;
unknown_count: number;
fixable_count: number;
secret_count: number;
misconfig_count: number;
scanned_at: number;
}>;
const out: Record<string, ScanSummary> = {};
@@ -4486,6 +4490,8 @@ export class DatabaseService {
low: r.low_count,
unknown: r.unknown_count,
fixable: r.fixable_count,
secret_count: r.secret_count,
misconfig_count: r.misconfig_count,
scanned_at: r.scanned_at,
scan_id: r.scan_id,
};
@@ -4493,6 +4499,43 @@ export class DatabaseService {
return out;
}
/**
* Uncapped count of scans in a given status for a node. Unlike
* `getVulnerabilityScans`, this never applies the per-image history cap, so
* the Security overview reports the true number of (for example) failed
* scans rather than a capped grouped total.
*/
public countScansByStatus(nodeId: number, status: VulnScanStatus): number {
return (
this.db
.prepare(
'SELECT COUNT(*) AS cnt FROM vulnerability_scans WHERE node_id = ? AND status = ?',
)
.get(nodeId, status) as { cnt: number }
).cnt;
}
/**
* Count of enabled block-on-deploy policies that are eligible to apply to
* this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on
* `getScanPoliciesForUi` so a replica never counts policies scoped to a
* sibling node's identity. Stack-pattern applicability is not evaluated
* (there is no concrete stack name at overview scope), so this is an
* approximate "is this node enforcing" indicator, not a per-stack guarantee.
*/
public countEligibleBlockPolicies(
nodeId: number,
role: 'control' | 'replica',
selfIdentity: string,
): number {
return this.getScanPoliciesForUi(role, selfIdentity).filter(
(p) =>
p.enabled === 1 &&
p.block_on_deploy === 1 &&
(p.node_id === null || p.node_id === nodeId),
).length;
}
// --- Scan Policies ---
public getScanPolicies(): ScanPolicy[] {
+255
View File
@@ -0,0 +1,255 @@
import type { VulnSeverity } from './DatabaseService';
/**
* Static, read-only policy-pack catalog.
*
* Policy packs are curated bundles of security expectations for a deployment
* posture (homelab, production, public edge, ...). This module is the single
* source of truth for the catalog: it has no database, no I/O, and no
* enforcement wiring. The Security page renders it as an educational reference
* so operators can understand what good looks like for their environment.
*
* Each rule carries a stable id, a severity, plain-language explanations
* (what it checks, why it matters, how to fix), and an `enforcement` marker.
* `warning` rules are advisory; `enforceable` rules are the ones a future
* enforcement phase can promote into a block-on-deploy scan policy. The marker
* is metadata only here: nothing in this module blocks a deploy.
*/
export type PolicyRuleEnforcement = 'warning' | 'enforceable';
export interface PolicyPackRule {
/** Stable identifier for the rule within the catalog. */
id: string;
name: string;
severity: Exclude<VulnSeverity, 'UNKNOWN'>;
/** What the rule inspects in a Compose stack or image. */
whatItChecks: string;
/** Why the check matters for security or reliability. */
why: string;
/** Concrete remediation guidance. */
howToFix: string;
/** Advisory (`warning`) or promotable to a block-on-deploy policy (`enforceable`). */
enforcement: PolicyRuleEnforcement;
}
export interface PolicyPack {
/** Stable identifier for the pack. */
id: string;
name: string;
/** One-line description of the posture the pack targets. */
tagline: string;
/** Descriptive copy about how the pack is meant to be used. */
tierCopy: string;
rules: PolicyPackRule[];
}
/**
* Canonical rule definitions, keyed by id. Packs reference these so the
* what/why/how copy stays consistent everywhere a rule appears; each pack
* sets its own `enforcement` level for the rule.
*/
type RuleDefinition = Omit<PolicyPackRule, 'enforcement'>;
const RULE_CATALOG = {
'no-privileged': {
id: 'no-privileged',
name: 'No privileged containers',
severity: 'CRITICAL',
whatItChecks: 'Services that set privileged: true.',
why: 'A privileged container can access all host devices and effectively escape isolation, so a single compromised service can take over the host.',
howToFix: 'Remove privileged: true and grant only the specific capabilities or device mounts the workload actually needs.',
},
'no-docker-socket': {
id: 'no-docker-socket',
name: 'No Docker socket mounts',
severity: 'CRITICAL',
whatItChecks: 'Bind mounts of /var/run/docker.sock into a container.',
why: 'Access to the Docker socket is equivalent to root on the host: the container can start new privileged containers or read every other stack.',
howToFix: 'Drop the socket mount. Where a tool genuinely needs Docker access, use a scoped proxy with a read-only, filtered API surface.',
},
'no-host-network': {
id: 'no-host-network',
name: 'No host networking',
severity: 'HIGH',
whatItChecks: 'Services using network_mode: host.',
why: 'Host networking removes network namespace isolation, exposes every container port on the host directly, and bypasses Compose network segmentation.',
howToFix: 'Use a bridge network and publish only the ports you need with explicit port mappings.',
},
'no-broad-bind-mounts': {
id: 'no-broad-bind-mounts',
name: 'No broad host bind mounts',
severity: 'HIGH',
whatItChecks: 'Bind mounts of sensitive host paths such as /, /etc, /var, or the host home directory.',
why: 'Mounting broad host paths lets a container read or modify host configuration and other services data, widening the blast radius of a compromise.',
howToFix: 'Mount only the specific subdirectory the service needs, and prefer named volumes for persistent data.',
},
'healthcheck-defined': {
id: 'healthcheck-defined',
name: 'Healthcheck defined',
severity: 'MEDIUM',
whatItChecks: 'Services that declare no healthcheck.',
why: 'Without a healthcheck the orchestrator cannot tell a hung container from a healthy one, so failures go unnoticed and dependent services start too early.',
howToFix: 'Add a healthcheck with a command that reflects real readiness, plus sensible interval, timeout, and retries.',
},
'no-public-db-ports': {
id: 'no-public-db-ports',
name: 'No public database ports',
severity: 'HIGH',
whatItChecks: 'Database services that publish their port to the host (for example 5432, 3306, 27017, 6379).',
why: 'Publishing a database port exposes it to anything that can reach the host, a common path to data theft on internet-adjacent machines.',
howToFix: 'Remove the host port mapping and let other services reach the database over the internal Compose network instead.',
},
'run-as-non-root': {
id: 'run-as-non-root',
name: 'Run as non-root',
severity: 'MEDIUM',
whatItChecks: 'Containers that run as root when a non-root user is available.',
why: 'Running as root raises the impact of a container breakout and of any write to a mounted host path.',
howToFix: 'Set a non-root user with the user directive, or use an image that ships a dedicated runtime user.',
},
'pin-image-tag': {
id: 'pin-image-tag',
name: 'Pin image tags',
severity: 'LOW',
whatItChecks: 'Images referenced by the latest tag or with no tag at all.',
why: 'A floating tag makes deploys non-reproducible: the same Compose file can pull different code on different days, including a regressed or compromised build.',
howToFix: 'Pin a specific version tag, and pin a digest for the strongest guarantee.',
},
'restart-policy': {
id: 'restart-policy',
name: 'Restart policy set',
severity: 'LOW',
whatItChecks: 'Services with no restart policy.',
why: 'Without a restart policy a crashed service stays down until someone notices, which turns a transient fault into an outage.',
howToFix: 'Set restart: unless-stopped (or on-failure) so the service recovers from crashes and host reboots.',
},
'no-plaintext-secrets': {
id: 'no-plaintext-secrets',
name: 'No plaintext secrets',
severity: 'HIGH',
whatItChecks: 'Credentials, tokens, or keys detected in Compose files, env values, or image layers.',
why: 'Secrets committed alongside a stack leak through backups, version control, and image registries, and are trivial to extract from a pulled image.',
howToFix: 'Move secrets into an .env file kept out of version control, or a secrets manager, and reference them by variable.',
},
'resource-limits': {
id: 'resource-limits',
name: 'Resource limits set',
severity: 'LOW',
whatItChecks: 'Services with no memory or CPU limits.',
why: 'An unbounded service can exhaust host memory or CPU and starve every other stack on the node.',
howToFix: 'Set memory and CPU limits sized to the workload so one service cannot monopolize the host.',
},
'pin-digest': {
id: 'pin-digest',
name: 'Pin image digest',
severity: 'LOW',
whatItChecks: 'Images that are not pinned to a content digest.',
why: 'A tag can be repointed at a different image after you have reviewed it; a digest is immutable and guarantees you run exactly what you vetted.',
howToFix: 'Reference the image by digest (image@sha256:...) for workloads that need supply-chain certainty.',
},
'read-only-rootfs': {
id: 'read-only-rootfs',
name: 'Read-only root filesystem',
severity: 'MEDIUM',
whatItChecks: 'Containers whose root filesystem is writable.',
why: 'A writable root filesystem lets an attacker drop tools or persist a foothold inside the container.',
howToFix: 'Set read_only: true and mount tmpfs or named volumes for the few paths that must be writable.',
},
'drop-capabilities': {
id: 'drop-capabilities',
name: 'Drop unnecessary capabilities',
severity: 'MEDIUM',
whatItChecks: 'Containers that keep the default Linux capability set instead of dropping what they do not use.',
why: 'Extra capabilities give a compromised process more ways to affect the host than the workload actually requires.',
howToFix: 'Drop ALL capabilities and add back only the ones the service needs (cap_drop / cap_add).',
},
} satisfies Record<string, RuleDefinition>;
// Closed set of rule ids derived from the catalog. Typing `rule()` against this
// turns a mistyped or deleted id into a compile error instead of a runtime throw.
type RuleId = keyof typeof RULE_CATALOG;
function rule(id: RuleId, enforcement: PolicyRuleEnforcement): PolicyPackRule {
return { ...RULE_CATALOG[id], enforcement };
}
/**
* The default catalog. Frozen so callers cannot mutate the shared definitions.
* Order is intentional: gentlest posture first, strictest last.
*/
export const DEFAULT_POLICY_PACKS: readonly PolicyPack[] = Object.freeze([
{
id: 'homelab-baseline',
name: 'Homelab baseline',
tagline: 'Gentle defaults for a single-operator homelab.',
tierCopy: 'Advisory guidance that flags the few habits worth keeping without getting in your way.',
rules: [
rule('no-plaintext-secrets', 'warning'),
rule('pin-image-tag', 'warning'),
rule('run-as-non-root', 'warning'),
rule('restart-policy', 'warning'),
],
},
{
id: 'production-hardening',
name: 'Production hardening',
tagline: 'Sensible hardening for services that face real traffic.',
tierCopy: 'Warns on risky exposure, missing healthchecks, and the highest-impact misconfigurations.',
rules: [
rule('no-privileged', 'enforceable'),
rule('no-docker-socket', 'enforceable'),
rule('no-plaintext-secrets', 'enforceable'),
rule('no-host-network', 'warning'),
rule('healthcheck-defined', 'warning'),
rule('drop-capabilities', 'warning'),
rule('read-only-rootfs', 'warning'),
rule('pin-image-tag', 'warning'),
],
},
{
id: 'strict-production',
name: 'Strict production',
tagline: 'Zero-tolerance posture for critical workloads.',
tierCopy: 'The strictest baseline, intended for workloads where reproducibility and isolation are non-negotiable.',
rules: [
rule('no-privileged', 'enforceable'),
rule('no-docker-socket', 'enforceable'),
rule('no-host-network', 'enforceable'),
rule('no-broad-bind-mounts', 'enforceable'),
rule('no-plaintext-secrets', 'enforceable'),
rule('run-as-non-root', 'enforceable'),
rule('healthcheck-defined', 'enforceable'),
rule('resource-limits', 'enforceable'),
rule('pin-digest', 'enforceable'),
],
},
{
id: 'public-edge',
name: 'Public edge service',
tagline: 'Focused on services exposed to the public internet.',
tierCopy: 'Emphasizes secret leakage, exposed ports, and the misconfigurations that matter most at the edge.',
rules: [
rule('no-plaintext-secrets', 'enforceable'),
rule('no-public-db-ports', 'enforceable'),
rule('no-host-network', 'enforceable'),
rule('no-privileged', 'enforceable'),
rule('healthcheck-defined', 'warning'),
rule('pin-image-tag', 'warning'),
],
},
{
id: 'internal-service',
name: 'Internal service',
tagline: 'Least-privilege defaults for east-west internal services.',
tierCopy: 'Avoids public exposure and broad host access while keeping internal services easy to run.',
rules: [
rule('no-public-db-ports', 'warning'),
rule('no-broad-bind-mounts', 'warning'),
rule('run-as-non-root', 'warning'),
rule('drop-capabilities', 'warning'),
rule('resource-limits', 'warning'),
rule('pin-image-tag', 'warning'),
],
},
]);
+1
View File
@@ -173,6 +173,7 @@
"features/rbac",
"features/sso",
"features/api-tokens",
"features/security",
"features/vulnerability-scanning",
"features/cve-suppressions",
"features/private-registries"
+1 -1
View File
@@ -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.
</Accordion>
<Accordion title="A deploy was blocked by vulnerability policy">
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.
</Accordion>
<Accordion title="Compose content was rejected on save">
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.
+7 -7
View File
@@ -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**.
<Frame>
<img src="/images/cve-suppressions/settings-panel.png" alt="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" />
<img src="/images/cve-suppressions/settings-panel.png" alt="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" />
</Frame>
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.
</Accordion>
<Accordion title="A suppressed CVE still blocks my deploy">
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.
</Accordion>
<Accordion title="A suppression I added on the control is not visible on a replica">
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.
+2 -2
View File
@@ -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: <SEVERITY>` 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: <SEVERITY>` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons.
<Frame>
<img src="/images/deploy-enforcement/policy-list.png" alt="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
<Accordion title="The gate let a deploy through even though I have a block policy">
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.
</Accordion>
+7 -7
View File
@@ -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).
<Frame>
<img src="/images/fleet-sync/control-security.png" alt="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'." />
<img src="/images/fleet-sync/control-security.png" alt="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'." />
</Frame>
## 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.
<Frame>
<img src="/images/fleet-sync/remote-security-via-proxy.png" alt="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.'" />
<img src="/images/fleet-sync/remote-security-via-proxy.png" alt="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.'" />
</Frame>
- **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.
</Accordion>
<Accordion title="I want this replica to be a standalone control again">
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.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
<Note>
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.
+83
View File
@@ -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).
+12 -12
View File
@@ -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.
<Frame>
<img src="/images/vulnerability-scanning/resources-badges.png" alt="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.
<Note>
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.
</Note>
## 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.
<Frame>
<img src="/images/vulnerability-scanning/security-settings.png" alt="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" />
<img src="/images/vulnerability-scanning/security-settings.png" alt="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" />
</Frame>
### 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.
<Frame>
<img src="/images/vulnerability-scanning/scan-history-sheet.png" alt="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
<AccordionGroup>
<Accordion title="Scan button is not visible">
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.
</Accordion>
<Accordion title="Scans time out">
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.
</Accordion>
<Accordion title="A deploy was blocked by a policy I did not expect">
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.
</Accordion>
<Accordion title="Trivy is not installed and a deploy with a block policy went through">
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 "<stack>" 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 "<stack>" 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.
</Accordion>
<Accordion title="Compare button is disabled in the scan history sheet">
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.
</Accordion>
<Accordion title="Scan policies are missing on one of my nodes">
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.
</Accordion>
<Accordion title="I suppressed a CVE but the scan badge count is unchanged">
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.
</Accordion>
<Accordion title="The Secrets tab is empty on an image I expect to contain credentials">
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.
+5 -5
View File
@@ -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.
<Frame>
<img src="/images/vulnerability-scanning/trivy-settings-card.png" alt="Vulnerability Scanner card in Settings, Security section, with Install Trivy button" />
<img src="/images/vulnerability-scanning/trivy-settings-card.png" alt="Vulnerability Scanner card on the Security page Scanner setup tab, with Install Trivy button" />
</Frame>
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.
+5 -42
View File
@@ -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** `<node name>` | Setting is per-node and is currently being edited against this node |
| **EDITED** `<count>` pending / `saved` | The current section has unsaved changes |
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>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 <n> left` (Account); `PLAN`, `TRIAL <n>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.
<Frame>
<img src="/images/settings/settings-search.png" alt="Settings command palette filtered to a webhook query" />
@@ -432,44 +433,6 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough.
---
## Vulnerability Scanning
<Note>
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.
</Note>
**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.
<Frame>
<img src="/images/settings/settings-security.png" alt="Security section showing the Trivy installer card and the empty scan-policy state" />
</Frame>
### 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
+3
View File
@@ -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}
/>
</div>
@@ -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 <ResourcesView />;
}
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 (
<LazyView>
<SecurityView activeTab={securityTab} onTabChange={onSecurityTabChange} />
</LazyView>
);
}
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
@@ -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');
});
});
@@ -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<ActiveView>('dashboard');
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(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,
@@ -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')
@@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record<ActiveView, MobileTreatment> = {
settings: 'bespoke',
editor: 'detail',
resources: 'responsive',
security: 'responsive',
templates: 'responsive',
'global-observability': 'responsive',
'auto-updates': 'responsive',
+4 -1
View File
@@ -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() {
+3 -81
View File
@@ -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<VulnSeverity | 'CLEAN', string> = {
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<VulnSeverity | 'CLEAN', string> = {
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<string>('');
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 (
<CursorProvider>
<CursorContainer className="inline-flex">
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
SEVERITY_BADGE_CLASSES[key],
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
{label}
</button>
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow side="bottom" align="end" sideOffset={8}>
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
<div className="font-mono tabular-nums text-xs space-y-1">
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
<div className="text-stat-value">{relative}</div>
{summary.total > 0 && (
<div className="flex gap-3 mt-1">
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
{summary.medium > 0 && <span className="text-warning">{summary.medium}M</span>}
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
</div>
)}
{summary.total === 0 && (
<div className="text-success">No vulnerabilities</div>
)}
</div>
</div>
</CursorFollow>
</CursorProvider>
);
}
// ── Quick Clean Prune Button ───────────────────────────────────────────────────
interface PruneButtonProps {
@@ -920,7 +842,7 @@ export default function ResourcesView() {
className="border-border"
onClick={() => {
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'security-history' },
detail: { view: 'security', tab: 'history' },
}));
}}
title="View completed vulnerability scans and compare them"
+270
View File
@@ -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<SecurityOverview | null>(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<Record<string, ScanSummary>>({});
const [summariesLoading, setSummariesLoading] = useState(true);
const [summariesError, setSummariesError] = useState(false);
const [isReplica, setIsReplica] = useState(false);
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(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 (
<div className="h-full overflow-auto p-6">
<PageMasthead
kicker="SECURITY"
state={state}
tone={tone}
pulsing={pulsing}
className="rounded-lg mb-4"
metadata={overview ? [
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 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}
/>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4 max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="overview">
<TabsTrigger value="overview"><LayoutDashboard className="w-4 h-4 mr-1.5" />Overview</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="images">
<TabsTrigger value="images"><Boxes className="w-4 h-4 mr-1.5" />Images</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose"><FileWarning className="w-4 h-4 mr-1.5" />Compose risks</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets"><KeyRound className="w-4 h-4 mr-1.5" />Secrets</TabsTrigger>
</TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="policies">
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="suppressions">
<TabsTrigger value="suppressions"><EyeOff className="w-4 h-4 mr-1.5" />Suppressions</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="history">
<TabsTrigger value="history"><HistoryIcon className="w-4 h-4 mr-1.5" />History</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="scanner">
<TabsTrigger value="scanner"><Wrench className="w-4 h-4 mr-1.5" />Scanner setup</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
<TabsContent value="overview">
<OverviewTab overview={overview} loadError={overviewLoadError} onNavigate={onTabChange} />
</TabsContent>
<TabsContent value="images">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<ImagesTab summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
<TabsContent value="compose">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<FindingsTab kind="misconfig" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
<TabsContent value="secrets">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<FindingsTab kind="secret" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
<TabsContent value="policies">
<div className="space-y-8">
<PolicyPacksTab />
<ScanPolicyManager />
</div>
</TabsContent>
<TabsContent value="suppressions">
{isRemote ? (
<div className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3">
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Managed on the local instance</div>
<p className="text-xs text-muted-foreground mt-0.5">
Suppressions and acknowledgements are managed on the local Sencho instance. Switch to the local node to view them.
</p>
</div>
</div>
) : (
<div className="space-y-6">
<SuppressionsPanel isReplica={isReplica} />
<MisconfigAckPanel isReplica={isReplica} />
</div>
)}
</TabsContent>
<TabsContent value="history">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 flex items-center justify-between gap-4 flex-wrap">
<div className="min-w-0">
<h3 className="font-medium text-sm">Scan history</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{overview
? `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned · last scan ${overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}`
: 'Browse completed scans and compare them.'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
<HistoryIcon className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Open scan history
</Button>
</div>
</CapabilityGate>
</TabsContent>
<TabsContent value="scanner">
<ScannerSetupTab />
</TabsContent>
</Tabs>
<SecurityHistoryView open={historyOpen} onClose={() => setHistoryOpen(false)} />
<VulnerabilityScanSheet
scanId={inspectScanId}
initialTab={inspectInitialTab}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin}
canCompare
canManageSuppressions={isAdmin}
/>
</div>
);
}
@@ -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);
@@ -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
<Row
label="Vulnerability scanning"
value={formatCount(security.scanPolicies.enabled, 'policy')}
onClick={open('security')}
onClick={() => window.dispatchEvent(
new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'policies' } }),
)}
/>
)}
@@ -54,7 +54,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
<SettingsSectionContent sectionId={activeSection} isPaid={isPaid} onDirtyChange={NOOP} showDescription />
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
</div>
</div>
);
@@ -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<string, ScanSummary>;
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<FindingsKind, {
icon: typeof KeyRound;
detailTab: ScanDetailTab;
countField: 'secret_count' | 'misconfig_count';
emptyTitle: string;
emptyBody: string;
intro?: string;
}> = {
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:<name>`).
.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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
<p className="text-sm text-muted-foreground max-w-md">Scan results failed to load for this node. Try again shortly.</p>
</div>
);
}
if (loading) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
);
}
return (
<div className="space-y-4">
{copy.intro && <p className="text-sm text-muted-foreground max-w-2xl">{copy.intro}</p>}
{rows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">{copy.emptyTitle}</h3>
<p className="text-sm text-muted-foreground max-w-md">{copy.emptyBody}</p>
</div>
) : (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">
{kind === 'misconfig' ? 'Stack' : 'Image'}
</th>
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Findings</th>
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Severity</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const label = kind === 'misconfig' ? s.image_ref.replace(/^stack:/, '') : s.image_ref;
const count = s[copy.countField];
return (
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, copy.detailTab)}>
{label}
</button>
</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums text-xs text-stat-value">{count}</td>
<td className="px-4 py-2.5 text-right max-md:hidden">
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, copy.detailTab)} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -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<string, ScanSummary>;
loading: boolean;
/** True when the summaries fetch failed; render an error state, never a false "clean". */
error?: boolean;
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
}
/** 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 (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
<p className="text-sm text-muted-foreground">Scan results failed to load for this node. Try again shortly.</p>
</div>
);
}
if (loading) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
);
}
if (images.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
</div>
);
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Image</th>
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Findings</th>
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Severity</th>
</tr>
</thead>
<tbody>
{images.map((s) => (
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
{s.image_ref}
</button>
</td>
<td className="px-4 py-2.5 font-mono tabular-nums text-xs text-stat-subtitle max-md:hidden">
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
{s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && <span className="text-success">clean</span>}
</td>
<td className="px-4 py-2.5 text-right">
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, 'vulns')} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -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 (
<div className="flex items-center justify-between gap-4 py-[var(--density-cell-y)]">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">{label}</span>
<span className={`font-mono tabular-nums text-sm ${toneClass}`}>{value}</span>
</div>
);
}
export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) {
if (loadError === 'unsupported') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ShieldOff className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Overview unavailable on this node</h3>
<p className="text-sm text-muted-foreground">
This node does not report a security overview. Browse images, history, and scanner setup directly.
</p>
</div>
);
}
if (loadError === 'failed') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ShieldOff className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Couldn't load the overview</h3>
<p className="text-sm text-muted-foreground">
The security overview failed to load for this node. Switch nodes and back, or try again shortly.
</p>
</div>
);
}
if (!overview) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</div>
);
}
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 (
<div className="space-y-6">
{/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail
instead of crushing the fixed columns. */}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
<div className="min-w-[640px]">
<SignalRail tiles={tiles} className="border-b-0" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Scanner</h3>
<StatusRow label="Status" value={scannerValue} tone={overview.scanner.available ? 'value' : 'warn'} />
{overview.scanner.source === 'managed' && (
<StatusRow label="Auto-update" value={overview.scanner.autoUpdate ? 'on' : 'off'} tone="subtitle" />
)}
<StatusRow
label="Last scan"
value={overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}
tone="subtitle"
/>
{!overview.scanner.available && (
<button
type="button"
onClick={() => onNavigate('scanner')}
className="mt-2 text-xs text-brand hover:underline"
>
Set up the scanner
</button>
)}
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Deploy enforcement</h3>
<StatusRow
label="Block policies"
value={String(overview.deployEnforcement.eligibleBlockPolicies)}
tone={overview.deployEnforcement.eligibleBlockPolicies > 0 ? 'value' : 'subtitle'}
/>
<StatusRow
label="Honor suppressions"
value={overview.deployEnforcement.honorSuppressionsOnDeploy ? 'on' : 'off'}
tone="subtitle"
/>
<p className="mt-2 text-xs text-muted-foreground">
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
</p>
</div>
</div>
</div>
);
}
@@ -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<PolicyPackRule['severity'], string> = {
CRITICAL: 'text-destructive',
HIGH: 'text-warning',
MEDIUM: 'text-warning',
LOW: 'text-muted-foreground',
};
function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforcement'] }) {
const enforceable = enforcement === 'enforceable';
return (
<span
className={cn(
'inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em]',
enforceable
? 'border-brand/30 bg-brand/10 text-brand'
: 'border-card-border bg-muted/30 text-stat-subtitle',
)}
>
{enforceable ? 'Enforceable' : 'Warning'}
</span>
);
}
export function PolicyPacksTab() {
const [packs, setPacks] = useState<PolicyPack[] | null>(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 (
<p className="text-sm text-muted-foreground py-16 text-center">
Policy packs could not be loaded.
</p>
);
}
if (!packs) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</div>
);
}
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground max-w-2xl">
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.
</p>
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="border-b border-card-border px-4 py-3">
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
<p className="text-xs text-stat-subtitle mt-1">{pack.tierCopy}</p>
</div>
<ul className="divide-y divide-card-border/40">
{pack.rules.map((rule) => (
<li key={rule.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm">{rule.name}</span>
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
{rule.severity}
</span>
</div>
<EnforcementBadge enforcement={rule.enforcement} />
</div>
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
<dd className="text-stat-subtitle">{rule.why}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
</dl>
</li>
))}
</ul>
</div>
))}
</div>
);
}
@@ -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<ScanPolicy[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<PolicyFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'honor-suppressions'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false);
const [honorBusy, setHonorBusy] = useState(false);
const [fleetRole, setFleetRole] = useState<FleetRole>('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 (
<div className="space-y-6">
{isPaid && isAdmin && !isRemote && !isReplica && (
<div className="flex justify-end">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
{isAdmin && !isRemote && !isReplica && (
<SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" />
Add policy
</SettingsPrimaryButton>
)}
</div>
{isRemote && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Managed on the local instance</div>
<p className="text-xs text-muted-foreground mt-0.5">
Scan policies are managed on the local Sencho instance. Switch to the local node to manage them.
</p>
</div>
</div>
)}
@@ -364,95 +318,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div>
)}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">Vulnerability Scanner</span>
<Badge variant={TRIVY_SOURCE_BADGES[trivy.source].variant} className="text-[10px] shrink-0">
{TRIVY_SOURCE_BADGES[trivy.source].label}
</Badge>
{updateCheck?.updateAvailable && (
<Badge variant="secondary" className="text-[10px] shrink-0">
Update available to v{updateCheck.latest}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isAdmin && trivy.source === 'none' && (
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'install' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Install Trivy
</SettingsPrimaryButton>
)}
{isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'update' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Update
</Button>
)}
{isAdmin && trivy.source === 'managed' && (
<Button
size="sm"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setUninstallConfirm(true)}
disabled={trivyBusy !== null}
>
Uninstall
</Button>
)}
</div>
</div>
{trivy.source === 'managed' && trivy.version && (
<div className="text-xs text-stat-subtitle font-mono">Version: v{trivy.version}</div>
)}
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
)}
{trivy.source === 'managed' && isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-update Trivy</Label>
<p className="text-xs text-muted-foreground">
Check daily and install newer Trivy releases automatically.
</p>
</div>
<TogglePill
checked={trivy.autoUpdate}
onChange={handleAutoUpdateToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
{isRemote && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Scanner is per-node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
</p>
</div>
</div>
)}
{!isRemote && loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
@@ -460,7 +325,15 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div>
)}
{isPaid && !isRemote && !loading && policies.length === 0 && (
{!isRemote && !loading && loadError && (
<SettingsCallout
icon={<ShieldCheck className="h-4 w-4" />}
title="Couldn't load scan policies"
subtitle="Scan policies failed to load. Try again shortly."
/>
)}
{!isRemote && !loading && !loadError && policies.length === 0 && (
<SettingsCallout
icon={<ShieldCheck className="h-4 w-4" />}
title="No scan policies configured"
@@ -468,7 +341,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
/>
)}
{isPaid && !isRemote && !loading &&
{!isRemote && !loading &&
policies.map((policy) => (
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
@@ -520,7 +393,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div>
))}
{isPaid && isAdmin && !isRemote && (
{isAdmin && !isRemote && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-4 py-3">
<div className="min-w-0">
<Label className="text-sm">Honor suppressions in deploy blocks</Label>
@@ -531,117 +404,95 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
<TogglePill
checked={trivy.honorSuppressionsOnDeploy}
onChange={handleHonorSuppressionsToggle}
disabled={trivyBusy !== null}
disabled={honorBusy}
/>
</div>
)}
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
{isPaid && (
<>
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
title={editingId ? 'Edit policy' : 'New policy'}
description="Configure the severity threshold and scope for this scan policy."
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
title={editingId ? 'Edit policy' : 'New policy'}
description="Configure the severity threshold and scope for this scan policy."
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="policy-name">Name</Label>
<Input
id="policy-name"
placeholder="Production block on critical"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="policy-name">Name</Label>
<Input
id="policy-name"
placeholder="Production block on critical"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
<Input
id="policy-pattern"
placeholder="e.g. prod-* or leave blank for all"
value={form.stack_pattern}
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
</p>
</div>
<div className="space-y-2">
<Label>Max severity</Label>
<Combobox
options={SEVERITY_OPTIONS}
value={form.max_severity}
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Block on deploy</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<TogglePill
checked={form.block_on_deploy}
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div>
<TogglePill
checked={form.enabled}
onChange={(c) => setForm({ ...form, enabled: c })}
/>
</div>
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
</div>
<div className="space-y-2">
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
<Input
id="policy-pattern"
placeholder="e.g. prod-* or leave blank for all"
value={form.stack_pattern}
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
/>
</Modal>
<ConfirmModal
open={deleteId != null}
onOpenChange={(open) => !open && setDeleteId(null)}
variant="destructive"
kicker="SECURITY · DELETE · IRREVERSIBLE"
title="Delete scan policy"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Removes the policy immediately. Existing scans are not affected.
<p className="text-xs text-muted-foreground">
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
</p>
</ConfirmModal>
</>
)}
</div>
<div className="space-y-2">
<Label>Max severity</Label>
<Combobox
options={SEVERITY_OPTIONS}
value={form.max_severity}
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Block on deploy</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<TogglePill
checked={form.block_on_deploy}
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div>
<TogglePill
checked={form.enabled}
onChange={(c) => setForm({ ...form, enabled: c })}
/>
</div>
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
/>
</Modal>
<ConfirmModal
open={uninstallConfirm}
onOpenChange={setUninstallConfirm}
open={deleteId != null}
onOpenChange={(open) => !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}
>
<p className="text-sm text-stat-subtitle">
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.
</p>
</ConfirmModal>
@@ -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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
Vulnerability scanning uses Trivy, installed independently on each node. Manage the scanner for the
active node here.
</p>
<TrivyManager
status={status}
updateCheck={updateCheck}
refresh={refresh}
refreshUpdateCheck={refreshUpdateCheck}
/>
{isRemote && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Scanner is per-node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
</p>
</div>
</div>
)}
</div>
);
}
@@ -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<TrivySource, { 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<TrivySource, 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' },
};
interface TrivyManagerProps {
status: TrivyStatus;
updateCheck: TrivyUpdateCheck | null;
refresh: () => Promise<void>;
refreshUpdateCheck: () => Promise<void>;
}
/**
* 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 | 'install' | 'update' | 'uninstall' | 'auto-update'>(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 (
<>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">Vulnerability Scanner</span>
<Badge variant={TRIVY_SOURCE_BADGES[status.source].variant} className="text-[10px] shrink-0">
{TRIVY_SOURCE_BADGES[status.source].label}
</Badge>
{updateCheck?.updateAvailable && (
<Badge variant="secondary" className="text-[10px] shrink-0">
Update available to v{updateCheck.latest}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isAdmin && status.source === 'none' && (
<SettingsPrimaryButton size="sm" onClick={handleInstall} disabled={trivyBusy !== null}>
{trivyBusy === 'install' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Install Trivy
</SettingsPrimaryButton>
)}
{isAdmin && status.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdate} disabled={trivyBusy !== null}>
{trivyBusy === 'update' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Update
</Button>
)}
{isAdmin && status.source === 'managed' && (
<Button
size="sm"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setUninstallConfirm(true)}
disabled={trivyBusy !== null}
>
Uninstall
</Button>
)}
</div>
</div>
{status.source === 'managed' && status.version && (
<div className="text-xs text-stat-subtitle font-mono">Version: v{status.version}</div>
)}
{TRIVY_SOURCE_DESCRIPTIONS[status.source] && (
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[status.source]}</div>
)}
{status.source === 'managed' && isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-update Trivy</Label>
<p className="text-xs text-muted-foreground">
Check daily and install newer Trivy releases automatically.
</p>
</div>
<TogglePill
checked={status.autoUpdate}
onChange={handleAutoUpdateToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
<ConfirmModal
open={uninstallConfirm}
onOpenChange={setUninstallConfirm}
variant="destructive"
kicker="TRIVY · REMOVE · IRREVERSIBLE"
title="Remove Trivy"
confirmLabel="Remove"
onConfirm={handleUninstall}
>
<p className="text-sm text-stat-subtitle">
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
</p>
</ConfirmModal>
</>
);
}
@@ -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<ScanSummary> & { 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(<FindingsTab kind="secret" summaries={summaries} loading={false} onInspect={onInspect} />);
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(<FindingsTab kind="misconfig" summaries={summaries} loading={false} onInspect={onInspect} />);
// 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(<FindingsTab kind="secret" summaries={{}} loading={false} onInspect={vi.fn()} />);
expect(screen.getByText('No secret findings')).toBeInTheDocument();
});
@@ -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<typeof vi.fn>;
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(<PolicyPacksTab />);
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(<PolicyPacksTab />);
await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument());
expect(screen.getByText('Enforceable')).toBeInTheDocument();
});
@@ -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<typeof vi.fn>;
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<typeof LicenseContext.useLicense>);
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
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(<ScanPolicyManager />);
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(<ScanPolicyManager />);
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(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument());
});
@@ -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>): 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' });
});
@@ -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' };
}
@@ -194,7 +194,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
<div className="px-7 pt-6 pb-8 flex flex-col gap-6 min-w-0">
<SettingsSectionContent
sectionId={safeSection}
isPaid={isPaid}
onDirtyChange={handleDirtyChange}
showDescription
/>
@@ -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 <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => 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 (
<>
@@ -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', () => {
+1 -1
View File
@@ -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
@@ -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',
@@ -54,7 +54,6 @@ export type SectionId =
| 'fleet-mesh'
| 'notifications'
| 'webhooks'
| 'security'
| 'cloud-backup'
| 'developer'
| 'data-retention'
@@ -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<string>('');
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 (
<CursorProvider>
<CursorContainer className="inline-flex">
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
SEVERITY_BADGE_CLASSES[key],
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
{label}
</button>
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow side="bottom" align="end" sideOffset={8}>
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
<div className="font-mono tabular-nums text-xs space-y-1">
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
<div className="text-stat-value">{relative}</div>
{summary.total > 0 && (
<div className="flex gap-3 mt-1">
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
{summary.medium > 0 && <span className="text-warning">{summary.medium}M</span>}
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
</div>
)}
{summary.total === 0 && hasNonVulnFindings && (
<div className="flex gap-3 mt-1 text-warning">
{(summary.secret_count ?? 0) > 0 && <span>{summary.secret_count} secret</span>}
{(summary.misconfig_count ?? 0) > 0 && <span>{summary.misconfig_count} misconfig</span>}
</div>
)}
{summary.total === 0 && !hasNonVulnFindings && (
<div className="text-success">No findings</div>
)}
</div>
</div>
</CursorFollow>
</CursorProvider>
);
}
@@ -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>): 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(<SeverityBadge summary={summary({ highest_severity: 'CRITICAL', total: 5, critical: 5 })} onClick={onClick} />);
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(<SeverityBadge summary={summary({ highest_severity: null })} onClick={() => {}} />);
expect(screen.getByRole('button', { name: /Clean/ })).toBeInTheDocument();
});
it('renders "Findings" (not "Clean") for a secret-only scan with no CVE severity', () => {
render(<SeverityBadge summary={summary({ highest_severity: null, secret_count: 2 })} onClick={() => {}} />);
expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Clean/ })).not.toBeInTheDocument();
});
it('renders "Findings" for a misconfig-only scan', () => {
render(<SeverityBadge summary={summary({ highest_severity: null, misconfig_count: 3 })} onClick={() => {}} />);
expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument();
});
+12
View File
@@ -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';
+27
View File
@@ -7,3 +7,30 @@ export const SEVERITY_ROW_TINT: Record<VulnSeverity, string> = {
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<VulnSeverity | 'CLEAN' | 'FINDINGS', string> = {
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<VulnSeverity | 'CLEAN' | 'FINDINGS', string> = {
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',
};
+49
View File
@@ -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<VulnSeverity, 'UNKNOWN'>;
whatItChecks: string;
why: string;
howToFix: string;
enforcement: PolicyRuleEnforcement;
}
export interface PolicyPack {
id: string;
name: string;
tagline: string;
tierCopy: string;
rules: PolicyPackRule[];
}