refactor: drop the advisory policy-packs section and the findings cursor tooltip (#1369)

* refactor: drop the advisory policy-packs section and the findings cursor tooltip

Two Security-page cleanups from review.

- Remove the advisory policy-packs catalog from the Policies tab. It was
  information-only and disconnected from the scan_policies enforcement engine, so
  it read as duplicated. The tab now hosts only the enforcement manager, which is
  paid, so the Policies tab is hidden for Community (with a deep-link guard) and
  the Overview's enforcement hint is gated to match. The backend policy-packs
  catalog and route are kept as a dormant foundation. Delete the orphaned
  PolicyPacksTab component, its test, and the unused frontend pack types.
- Drop the cursor-follow tooltip from the findings severity badge (Secrets and
  Compose risks), matching the Images table.
- Clarify that Compose risks is a Trivy security-misconfig audit, distinct from
  Compose Doctor's deploy-readiness preflight, in the tab copy and the docs.

* chore: re-run CI
This commit is contained in:
Anso
2026-06-12 23:24:32 -04:00
committed by GitHub
parent 4610a433e6
commit 3c116466d9
9 changed files with 43 additions and 274 deletions
+11 -20
View File
@@ -1,6 +1,6 @@
---
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."
description: "The command center for your fleet's security posture: an overview dashboard, image and Compose findings, secrets, scan history, suppressions, and scanner setup, all in one place."
---
Security is a primary surface in Sencho. The **Security** page in the top navigation brings the
@@ -30,32 +30,23 @@ 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.
Compose risks surface the security misconfigurations Trivy finds in your stack definitions rather than
image CVEs: privileged containers, Docker socket mounts, host networking, broad host bind mounts, public
database ports, and containers running as root. Each entry opens its scan report with the specific
findings and how to fix them. This is a security audit of the Compose file; for deploy-readiness checks
(port conflicts, missing bind paths, unset variables, no healthcheck), use
[Compose Doctor](/features/compose-doctor) from the stack page instead.
## 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
## Policies
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).
The Policies tab manages deploy-enforcement scan policies: severity thresholds that block or warn on a
deploy, scoped by stack pattern. Enforcement is an Admiral capability; see
[scan policies](/features/vulnerability-scanning#scan-policies) for the full configuration.
## Suppressions
+6 -2
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. 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.
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, 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" />
@@ -153,7 +153,7 @@ 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="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" />
<img src="/images/vulnerability-scanning/security-settings.png" alt="The Security page Policies tab showing the Deploy enforcement policies section with an Add policy button and the No scan policies configured empty state" />
</Frame>
### Creating a policy
@@ -246,6 +246,10 @@ Full scans take longer than vulnerability-only scans because Trivy reads every f
Beyond package CVEs, Sencho can run `trivy config` against a stack's Compose file to flag insecure defaults before you deploy. Typical checks cover privileged containers, missing resource limits, host networking, mounted Docker sockets, and overly broad capabilities.
<Note>
This is a **security** audit of the Compose file, using Trivy's hardening rules. It is distinct from [Compose Doctor](/features/compose-doctor), which runs **deploy-readiness** checks (port conflicts, missing bind paths, unset variables, no healthcheck) before a deploy. Use Compose Doctor to confirm a stack will deploy cleanly; use Compose risk scanning to audit its security posture.
</Note>
From any stack page, open the **More actions** overflow menu next to the Update button and select **Scan config**. Sencho runs the scanner against the stack's working directory and opens the scan drawer on the **Misconfigs** tab.
<Frame>
+16 -9
View File
@@ -21,7 +21,6 @@ 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';
import { HistoryTab } from './security/HistoryTab';
@@ -163,6 +162,12 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
return () => { cancelled = true; };
}, [isRemote, activeNode?.id]);
// The Policies tab hosts only the paid enforcement manager, so it is hidden for
// Community; redirect off it if a deep-link lands a Community user there.
useEffect(() => {
if (!isPaid && activeTab === 'policies') onTabChange('overview');
}, [isPaid, activeTab, onTabChange]);
const { state, tone } = deriveMasthead(overview, overviewLoadError !== null);
const pulsing = tone === 'live' && !!overview?.scanner.available;
@@ -201,9 +206,11 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
<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>
{isPaid && (
<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>
@@ -226,6 +233,7 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
onInspect={onInspect}
canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)}
isPaid={isPaid}
/>
</TabsContent>
@@ -255,12 +263,11 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
</CapabilityGate>
</TabsContent>
<TabsContent value="policies">
<div className="space-y-8">
{isPaid && (
<TabsContent value="policies">
<ScanPolicyManager />
<PolicyPacksTab />
</div>
</TabsContent>
</TabsContent>
)}
<TabsContent value="suppressions">
{isRemote ? (
@@ -35,8 +35,8 @@ const COPY: Record<FindingsKind, {
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.',
emptyBody: 'Run a node scan or a per-stack config scan to surface security misconfigurations like privileged containers, Docker socket mounts, or host networking.',
intro: 'Compose risks are the security misconfigurations Trivy finds in your stack definitions: privileged containers, Docker socket mounts, host networking, or broad capabilities. This is a security audit of the compose file. For deploy-readiness checks like port conflicts, missing bind paths, unset variables, or no healthcheck, run Compose Doctor from the stack page instead. Open a result for the specific findings and how to fix them.',
},
};
@@ -109,7 +109,7 @@ export function FindingsTab({ kind, summaries, loading, error, onInspect }: Find
</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)} />
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, copy.detailTab)} />
</td>
</tr>
);
@@ -25,6 +25,8 @@ interface OverviewTabProps {
canScan: boolean;
/** Refresh the overview after a node-wide scan completes. */
onScanComplete: () => void;
/** Paid licensees can manage enforcement policies (the Policies tab is hidden otherwise). */
isPaid: boolean;
}
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
@@ -52,7 +54,7 @@ function ChartCard({ title, className, children }: { title: string; className?:
);
}
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
if (loadError === 'unsupported') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
@@ -176,7 +178,7 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
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.
{isPaid ? 'Manage enforcement policies on the Policies tab. ' : ''}This is a read-only posture for the active node.
</p>
</div>
</div>
@@ -1,145 +0,0 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-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);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
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-16 w-full rounded-lg" />
<Skeleton className="h-16 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>
<div className="space-y-3">
{packs.map((pack) => {
const isOpen = expanded.has(pack.id);
return (
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<button
type="button"
onClick={() => toggle(pack.id)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-glass-highlight transition-colors"
>
{isOpen
? <ChevronDown className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />}
<div className="min-w-0 flex-1">
<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>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle shrink-0 tabular-nums">
{pack.rules.length} rule{pack.rules.length === 1 ? '' : 's'}
</span>
</button>
{isOpen && (
<div className="border-t border-card-border">
<p className="px-4 py-2 text-xs text-stat-subtitle">{pack.tierCopy}</p>
<ul className="divide-y divide-card-border/40 border-t border-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>
);
})}
</div>
</div>
);
}
@@ -243,8 +243,9 @@ export function ScanPolicyManager() {
}
};
// Enforcement management is a paid governance surface; Community sees only the
// policy-pack catalog above it.
// Enforcement management is a paid governance surface; the Policies tab is
// hidden for Community entirely (gated in SecurityView), so this is a
// defensive guard.
if (!isPaid) return null;
return (
@@ -1,71 +0,0 @@
/**
* 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';
import userEvent from '@testing-library/user-event';
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 reveals rules when a pack is expanded', async () => {
const user = userEvent.setup();
render(<PolicyPacksTab />);
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
expect(screen.getByText('Strict production')).toBeInTheDocument();
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
// Rules are collapsed behind the accordion until the pack header is clicked.
expect(screen.queryByText('Pin image tags')).not.toBeInTheDocument();
await user.click(screen.getByText('Homelab baseline'));
await user.click(screen.getByText('Strict production'));
expect(screen.getByText('Pin image tags')).toBeInTheDocument();
expect(screen.getByText('No privileged containers')).toBeInTheDocument();
});
it('labels expanded rules as warning or enforceable', async () => {
const user = userEvent.setup();
render(<PolicyPacksTab />);
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
await user.click(screen.getByText('Homelab baseline'));
await user.click(screen.getByText('Strict production'));
expect(screen.getByText('Warning')).toBeInTheDocument();
expect(screen.getByText('Enforceable')).toBeInTheDocument();
});
-20
View File
@@ -214,23 +214,3 @@ export interface SecurityRiskTrendPoint {
critical: number;
high: number;
}
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[];
}