feat: make all security features available on every tier (#1502)

Scan policies, deploy enforcement, the suppression-aware deploy-block
toggle, SARIF export, and OpenVEX export now work on Community, matching
the rest of the vulnerability-scanning surface that was already free.

Backend: drop the tier gate from the seven security routes and from the
dashboard configuration-status scan-policies row, so the Dashboard and
Fleet config cards stop hiding the Vulnerability scanning row. Reading
policies stays auth-only; mutations and exports stay admin-only.

Frontend: always show the Policies tab and panel, the SARIF and VEX
export actions, and the honor-suppressions toggle for admins.

Docs: move scan policies, SARIF, and OpenVEX to every tier across the
feature and API-reference pages; clarify that Fleet Sync's cross-node
replication remains the paid part.
This commit is contained in:
Anso
2026-06-28 08:14:21 -04:00
committed by GitHub
parent 05c483f213
commit 04e69021e0
27 changed files with 153 additions and 179 deletions
@@ -70,17 +70,16 @@ describe('GET /api/dashboard/configuration', () => {
});
});
it('keeps freed rows unlocked and only scanPolicies locked for Community', async () => {
it('keeps every freed row unlocked for Community', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
expect(res.status).toBe(200);
// routing rules, webhooks, and scheduled tasks are free.
// routing rules, webhooks, scheduled tasks, and scan policies are all free.
expect(res.body.notifications.routingRules.locked).toBe(false);
expect(res.body.automation.webhooks.locked).toBe(false);
expect(res.body.automation.scheduledTasks.locked).toBe(false);
// Scan policies stay paid-gated.
expect(res.body.security.scanPolicies.locked).toBe(true);
expect(res.body.security.scanPolicies.locked).toBe(false);
});
it('unlocks every gated row for the paid tier', async () => {
@@ -123,3 +123,47 @@ describe('PUT /api/security/policies/:id risk inputs', () => {
expect(res.body).toMatchObject({ block_on_kev: 1, block_on_fixable: 0 });
});
});
describe('scan policies on Community (no tier gate)', () => {
beforeEach(() => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
});
it('lets a Community admin create, read, update, and delete a policy', async () => {
const created = await post({ name: 'community-gate', max_severity: 'CRITICAL', block_on_deploy: 1 });
expect(created.status).toBe(201);
const id = created.body.id as number;
const list = await request(app).get('/api/security/policies').set('Authorization', adminAuthHeader);
expect(list.status).toBe(200);
expect((list.body as Array<{ id: number }>).some((p) => p.id === id)).toBe(true);
const updated = await request(app)
.put(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader)
.send({ name: 'community-gate-2' });
expect(updated.status).toBe(200);
const removed = await request(app)
.delete(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader);
expect(removed.status).toBe(200);
});
it('lets a Community viewer read policies but denies a write (admin gate is the sole guard)', async () => {
const db = DatabaseService.getInstance();
if (!db.getUserByUsername('pol-viewer')) {
db.addUser({ username: 'pol-viewer', password_hash: 'x', role: 'viewer' });
}
const viewerHeader = `Bearer ${jwt.sign({ username: 'pol-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const read = await request(app).get('/api/security/policies').set('Authorization', viewerHeader);
expect(read.status).toBe(200);
const write = await request(app)
.post('/api/security/policies')
.set('Authorization', viewerHeader)
.send({ name: 'viewer-blocked', max_severity: 'CRITICAL', block_on_deploy: 1 });
expect(write.status).toBe(403);
});
});
@@ -1,10 +1,10 @@
/**
* Tests for the role + tier gate on PUT /api/security/deploy-block-honor-suppressions.
* Tests for the role gate on PUT /api/security/deploy-block-honor-suppressions.
*
* The route flips the global `deploy_block_honor_suppressions` setting that the
* pre-deploy policy gate reads to decide whether a suppressed CVE still counts
* toward a block-on-deploy policy. It must be reachable only by an admin on a
* paid (Admiral) tier, matching the trivy-auto-update toggle.
* toward a block-on-deploy policy. It must be reachable by any admin on every
* tier; only the admin role is required.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -52,7 +52,7 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
expect(res.status).toBe(403);
});
it('rejects Community tier with 403', async () => {
it('accepts a Community tier admin (no tier gate)', async () => {
const { LicenseService } = await import('../services/LicenseService');
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try {
@@ -60,7 +60,8 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
.put('/api/security/deploy-block-honor-suppressions')
.set('Cookie', adminCookie)
.send({ enabled: true });
expect(res.status).toBe(403);
expect(res.status).toBe(200);
expect(res.body.honorSuppressionsOnDeploy).toBe(true);
} finally {
spy.mockReturnValue('paid');
}
@@ -445,7 +445,7 @@ describe('GET /api/security/scans/:scanId/vulnerabilities', () => {
});
});
describe('GET /api/security/vex/export (Admiral)', () => {
describe('GET /api/security/vex/export (Community)', () => {
beforeEach(() => {
resetSecurity();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
@@ -454,13 +454,17 @@ describe('GET /api/security/vex/export (Admiral)', () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
});
it('is gated to Admiral: 403 for Community', async () => {
it('lets a Community admin export (no tier gate)', async () => {
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
expect(res.status).toBe(200);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
const res = await request(app).get('/api/security/vex/export').set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('exports an OpenVEX document from triage decisions for Admiral', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
it('exports an OpenVEX document from triage decisions', async () => {
db().createCveSuppression({
cve_id: 'CVE-2024-2222', pkg_name: null, image_pattern: 'nginx*', reason: 'not present in build',
created_by: 'admin', created_at: Date.now(), expires_at: null, replicated_from_control: 0,
@@ -1,10 +1,7 @@
/**
* Tier split for the two scan-export endpoints:
* POST /api/security/sbom -> Community (admin only, no tier gate)
* GET /api/security/scans/:id/sarif -> Admiral (paid) only
*
* SBOM is a per-image artifact useful to any self-hoster; SARIF (CI/security
* pipeline ingestion) stays a paid governance export.
* Both scan-export endpoints are available on every tier (admin only, no tier gate):
* POST /api/security/sbom -> per-image SBOM artifact
* GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
@@ -68,24 +65,23 @@ describe('POST /api/security/sbom (Community)', () => {
});
});
describe('GET /api/security/scans/:scanId/sarif (Admiral only)', () => {
describe('GET /api/security/scans/:scanId/sarif (Community)', () => {
afterEach(() => { vi.restoreAllMocks(); mockTier('paid'); });
it('rejects a Community admin with 403 PAID_REQUIRED', async () => {
it('lets a Community admin reach the route (404 for a missing scan, not 403)', async () => {
mockTier('community');
const res = await request(app)
.get('/api/security/scans/999999/sarif')
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('passes the tier gate for a paid admin (404 for a missing scan, not 403)', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/security/scans/999999/sarif')
.set('Cookie', adminCookie);
expect(res.status).not.toBe(403);
expect(res.status).toBe(404);
});
it('denies a non-admin (viewer) with 403 (admin gate is the sole guard now)', async () => {
mockTier('community');
const res = await request(app)
.get('/api/security/scans/999999/sarif')
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
});
+2 -3
View File
@@ -52,7 +52,6 @@ export function buildLocalConfigurationStatus(
tier: LicenseTier,
): ConfigurationStatus {
const db = DatabaseService.getInstance();
const isPaid = tier === 'paid';
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
@@ -129,11 +128,11 @@ export function buildLocalConfigurationStatus(
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
ssoEnabled: !!enabledSso,
ssoProvider: enabledSso?.provider ?? null,
// Scan policies (deploy enforcement) require a paid license.
// Scan policies are available on every tier.
scanPolicies: {
total: scanPolicies.length,
enabled: scanPolicies.filter(p => p.enabled === 1).length,
locked: !isPaid,
locked: false,
},
},
thresholds: {
+6 -12
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { trivyInstallLimiter } from '../middleware/rateLimiters';
import TrivyService, { SbomFormat } from '../services/TrivyService';
import TrivyInstaller from '../services/TrivyInstaller';
@@ -320,7 +320,6 @@ securityRouter.put('/cve-intel-enabled', authMiddleware, (req: Request, res: Res
// deploys, against that node's own replicated suppression copy. Default off.
securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
// Require an explicit boolean so a stringy `"1"` cannot silently disable the
// gate (this toggle weakens a deploy block, so intent must be unambiguous).
if (typeof req.body?.enabled !== 'boolean') {
@@ -340,7 +339,7 @@ securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Req
// Pre-deploy scan advisory toggle. When on, a manual stack deploy first shows
// the latest cached scan severity for each image so the operator can review it
// before deploying. Visibility only: it never blocks (that is the paid
// before deploying. Visibility only: it never blocks (that is the
// deploy-block policy). Per-instance, admin-only, all tiers. Default off.
securityRouter.put('/pre-deploy-scan-advisory', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
@@ -1031,7 +1030,6 @@ securityRouter.get(
authMiddleware,
(req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -1092,12 +1090,10 @@ securityRouter.get(
},
);
// Export the instance's CVE triage decisions as an OpenVEX document. Authoring
// fleet VEX is a governance feature, so it is Admiral (paid) + admin, mirroring
// the SARIF export gate.
// Export the instance's CVE triage decisions as an OpenVEX document. Admin-only,
// mirroring the SARIF export.
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const suppressions = DatabaseService.getInstance().getCveSuppressions();
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString());
@@ -1111,7 +1107,8 @@ securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response):
});
securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
// Reading policies is not privileged, so this stays auth-only; only the
// mutation routes below require admin.
// Replicas see only policies that apply to themselves: local-only rows plus
// fleet-wide and self-identity-matched replicated rows. Identity-scoped
// rows targeting other replicas are filtered out at the SQL boundary.
@@ -1122,7 +1119,6 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v
securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim()) {
@@ -1173,7 +1169,6 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
@@ -1232,7 +1227,6 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
+1 -1
View File
@@ -116,7 +116,7 @@ Some endpoints are gated by license tier:
| Tier | Gated features |
|------|---------------|
| **Admiral** | Scan policies, Private registries |
| **Admiral** | Private registries (AWS ECR) |
Requests to gated endpoints on Community return `403` with the `PAID_REQUIRED` error code.
+5 -5
View File
@@ -5,7 +5,7 @@ description: Automate scan policies, CVE suppressions, and vulnerability scans f
The Security API lets you manage scan policies, CVE suppressions, and trigger vulnerability scans from CI pipelines and automation scripts. Every endpoint in this reference is intended for external automation; internal frontend-only endpoints (finding listings, SARIF downloads) are not documented here.
All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Manual scans, secret and misconfiguration results, scan comparison, and CVE suppressions are available on every tier. Scan policies (with `block_on_deploy` enforcement), SBOM, and SARIF require Admiral. See the per-endpoint **License** row for details.
All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Every endpoint in this reference is available on every tier, including scan policies with `block_on_deploy` enforcement. See the per-endpoint **Role** row for the admin requirement on writes.
## Scan policies
@@ -17,7 +17,7 @@ Writes are admin-only and rejected on replica nodes (policies are managed on the
**`GET /api/security/policies`**
**License:** Admiral
**License:** Community
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
@@ -51,7 +51,7 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \
**`POST /api/security/policies`**
**License:** Admiral · **Role:** Admin
**License:** Community · **Role:** Admin
| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
@@ -93,7 +93,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/policies \
**`PUT /api/security/policies/{id}`**
**License:** Admiral · **Role:** Admin
**License:** Community · **Role:** Admin
Any of the create fields can be updated individually. Omitted fields are left unchanged.
@@ -112,7 +112,7 @@ curl -X PUT https://your-sencho-instance:1852/api/security/policies/1 \
**`DELETE /api/security/policies/{id}`**
**License:** Admiral · **Role:** Admin
**License:** Community · **Role:** Admin
```bash
curl -X DELETE https://your-sencho-instance:1852/api/security/policies/1 \
+1 -1
View File
@@ -95,7 +95,7 @@ To change a suppression's scope (for example, to narrow an image pattern or exte
Suppressed findings carry through to the [SARIF export](/features/vulnerability-scanning#sarif-export) with a SARIF `suppressions` entry of `kind: external` and `status: accepted`. Code-scanning dashboards that respect SARIF suppressions dismiss those findings with the Reason you recorded.
Admiral instances can also export the full set of triage decisions as an **OpenVEX** document from the Suppressions panel. Each decision becomes a VEX statement (`not_affected`, `fixed`, `affected`, or `under_investigation`) with its justification, for use with VEX-aware scanners and supply-chain tooling.
You can also export the full set of triage decisions as an **OpenVEX** document from the Suppressions panel. Each decision becomes a VEX statement (`not_affected`, `fixed`, `affected`, or `under_investigation`) with its justification, for use with VEX-aware scanners and supply-chain tooling.
## Troubleshooting
+1 -1
View File
@@ -102,7 +102,7 @@ The card is divided into four sections.
|-----|---------------|
| **MFA** | `On` when TOTP is configured for the signed-in operator, `Off` when configured but disabled, `Not set up` when there is no MFA secret on file |
| **SSO** | The active SSO provider name (`OIDC`, `Google`, `GitHub`, `Okta`, `LDAP`); reads `Off` when SSO is not enabled |
| **Vulnerability scanning** (Admiral) | Count of enabled scan policies on the active node; reads `None` when no policy is enabled |
| **Vulnerability scanning** | Count of enabled scan policies on the active node; reads `None` when no policy is enabled |
### Backups & Thresholds
-4
View File
@@ -5,10 +5,6 @@ description: "Block deploys that violate a scan policy before docker compose up
Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. When a [scan policy](/features/vulnerability-scanning#scan-policies) with **Block on deploy** enabled matches a stack, Sencho scans every image referenced by the stack's compose file before starting any container. A policy gates on exploitation risk: a known-exploited CVE (CISA KEV), a fixable Critical/High finding, or a raw severity threshold. If any image matches an enabled condition, the deploy is rejected and the stack never starts. Detection always continues post-deploy and on a schedule, so images that develop new vulnerabilities after the initial deploy still surface through alerts.
<Note>
Deploy enforcement and scan policies require an **Admiral** license.
</Note>
## Configuring a block policy
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 a badge per active block condition (`KEV`, `Fixable`, and `max: <SEVERITY>` when the severity threshold is on), a `block` badge, the configured stack-pattern scope, and pencil and trash buttons.
+1 -1
View File
@@ -115,7 +115,7 @@ Demote requires `{"confirm": true}` in the request body to prevent a misclick fr
| Requirement | Why it matters |
|---|---|
| **A paid Sencho tier on the control instance** | Creating scan policies, CVE suppressions, and misconfig acknowledgements is a paid feature. Fleet Sync simply replicates rules that were created on the control, so a paid tier on the control is what enables the whole flow. Replicas accept pushes regardless of their own tier. |
| **A paid Sencho tier on the control instance** | Authoring scan policies, CVE suppressions, and misconfig acknowledgements works on every tier, but replicating them across a fleet is the paid part: Fleet Sync's cross-node replication and anchor controls require a paid tier on the control. Replicas accept pushes regardless of their own tier. |
| **Admin user role on the control** | Authoring the rules that replicate, and operating the re-anchor and demote endpoints on a replica, are all admin-only actions. Operator and viewer roles can read rules but cannot create or remove them. |
| **Proxy-mode remotes with `api_url` and `api_token` configured in Settings → Nodes** | Fleet Sync pushes over HTTPS to each remote's Sencho API using its long-lived bearer token. Remotes without an `api_url` or `api_token`, or remotes that connect over the pilot tunnel, are skipped. |
| **Network reachability from the control to each remote** | Pushes are HTTP requests originating on the control. A remote that is firewalled off, behind NAT without a forwarded port, or otherwise unreachable will queue retries until it returns. |
+1 -1
View File
@@ -47,7 +47,7 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
**Admiral** adds governance, security, and fleet control for teams. It includes everything in Community, plus:
- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window
- **Security:** Fleet Secrets, AWS ECR registry credentials, deploy enforcement (scan policies with `block_on_deploy`), SARIF export, and LDAP / Active Directory authentication
- **Security:** Fleet Secrets, AWS ECR registry credentials, and LDAP / Active Directory authentication
- **Fleet operations:** node cordon, Blueprints, and Sencho Mesh (cross-node container networking)
- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance)
- **Operator access:** the Host Console (a browser-based terminal on the Sencho host)
+3 -3
View File
@@ -49,7 +49,7 @@ Sencho snapshots your compose and environment files before applying changes. If
### Deploy enforcement
Block deploys that violate a scan policy before `docker compose up` runs, with an admin bypass path and a full audit trail. The pre-flight gate enumerates images and rejects deploys when any image meets or exceeds the policy's severity threshold; drift detection continues post-deploy and on schedule. Admiral. [Learn more →](/features/deploy-enforcement)
Block deploys that violate a scan policy before `docker compose up` runs, with an admin bypass path and a full audit trail. The pre-flight gate enumerates images and rejects deploys when any image meets or exceeds the policy's severity threshold; drift detection continues post-deploy and on schedule. [Learn more →](/features/deploy-enforcement)
### Blueprints
@@ -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 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)
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, scan policies that gate deploys, SARIF export, single-scan SBOM export, and auto-update of the managed Trivy binary are all available on every tier. [Learn more →](/features/vulnerability-scanning)
### CVE suppressions
@@ -201,4 +201,4 @@ When you manage multiple nodes running different Sencho versions, the dashboard
### Licensing & billing
Community is the complete self-hosted control plane, free forever. Admiral adds governance, security, and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, deploy enforcement, Blueprints, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing)
Community is the complete self-hosted control plane, free forever, including the full vulnerability-scanning and deploy-enforcement suite. Admiral adds governance and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, Blueprints, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing)
+3 -3
View File
@@ -72,7 +72,7 @@ the secret findings for a 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
deploy, scoped by stack pattern. See
[scan policies](/features/vulnerability-scanning#scan-policies) for the full configuration.
## Suppressions
@@ -83,8 +83,8 @@ node; switch to the local node to manage them.
Suppressing a CVE records a **triage decision**: accepted risk, not affected, false positive, fixed,
ignored, or needs review, with an optional OpenVEX justification. Decided findings stop driving the
action posture; a "needs review" decision stays counted but keeps the finding actionable. Admiral
instances can export the fleet's triage decisions as an OpenVEX document for use with other tooling.
action posture; a "needs review" decision stays counted but keeps the finding actionable. You can
export the fleet's triage decisions as an OpenVEX document for use with other tooling.
## History
+15 -31
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, 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, policy enforcement, SARIF export, single-scan SBOM export, and managed Trivy auto-update are all available on every tier.
<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" />
@@ -17,28 +17,20 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund
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
## What's included
| Feature | Community | Admiral |
|---------|:---------:|:-------:|
| Install / update / uninstall Trivy | ✓ | ✓ |
| On-demand image vulnerability scanning | ✓ | ✓ |
| Full scan (vulnerabilities + secrets) | ✓ | ✓ |
| Compose file misconfiguration scanning | ✓ | ✓ |
| Severity badges in the Resources Hub | ✓ | ✓ |
| Scan results drawer with grouped tabs | ✓ | ✓ |
| Post-deploy automated scanning | ✓ | ✓ |
| Pre-deploy scan advisory on manual deploys | ✓ | ✓ |
| Scan history sheet | ✓ | ✓ |
| Scan comparison | ✓ | ✓ |
| CVE suppressions | ✓ | ✓ |
| Misconfig acknowledgements | ✓ | ✓ |
| Scheduled fleet scans (all images on a node) | ✓ | ✓ |
| Scan policies with `block_on_deploy` enforcement | | ✓ |
| Suppression-aware deploy blocking (optional toggle) | | ✓ |
| SBOM generation (SPDX, CycloneDX) | ✓ | ✓ |
| SARIF export (code scanning integration) | | ✓ |
| Auto-update of the managed Trivy binary | ✓ | ✓ |
Every vulnerability-scanning capability is available on every tier:
- Install / update / uninstall Trivy, with auto-update of the managed binary
- On-demand image scanning and full scans (vulnerabilities + secrets)
- Compose file misconfiguration scanning
- Severity badges in the Resources Hub and a scan results drawer with grouped tabs
- Post-deploy automated scanning and a pre-deploy scan advisory on manual deploys
- Scan history sheet and scan comparison
- CVE suppressions and misconfig acknowledgements
- Scheduled fleet scans (all images on a node)
- Scan policies with `block_on_deploy` enforcement, including suppression-aware deploy blocking
- SBOM generation (SPDX, CycloneDX) and SARIF export for code-scanning integration
## On-demand scanning
@@ -85,7 +77,7 @@ The drawer opens as a right-side sheet with the breadcrumb `Security Scans
- **Re-scan**: kick off a fresh scan, ignoring the digest cache.
- **Compare**: pick a baseline scan from the dropdown to diff against this one.
- **CSV**: export the full vulnerability list for offline review.
- **SARIF**: download the full scan (vulnerabilities, secrets, and misconfigs) as SARIF 2.1.0 for upload to GitHub code scanning or any SARIF-aware tool. Admiral required.
- **SARIF**: download the full scan (vulnerabilities, secrets, and misconfigs) as SARIF 2.1.0 for upload to GitHub code scanning or any SARIF-aware tool.
The summary header below the actions reports the per-severity counts, the total, how many findings have a fix available, when the scan ran, and what triggered it. An **SBOM** button below the summary downloads a Software Bill of Materials in SPDX JSON or CycloneDX format.
@@ -159,10 +151,6 @@ Failures are usually transient (registry timeouts, missing credentials) and neve
## Scan policies
<Note>
Scan policies require an **Admiral** license.
</Note>
Policies define severity thresholds that govern whether a stack can deploy. A policy with **Block on deploy** enabled runs a pre-flight scan on every image in the stack before `docker compose up` executes; if any image meets or exceeds the threshold, the deploy is rejected with a dialog listing the offending images. Policies with **Block on deploy** disabled still evaluate every post-deploy and scheduled scan and dispatch warning alerts when the threshold is exceeded.
See [Deploy Enforcement](/features/deploy-enforcement) for the full pre-flight flow, admin bypass path, and audit-log behavior.
@@ -333,10 +321,6 @@ The download starts immediately and uses the image's digest (when available) in
## SARIF export
<Note>
SARIF export requires an **Admiral** license.
</Note>
SARIF (Static Analysis Results Interchange Format) is the standard format supported by GitHub code scanning, Microsoft Defender for Cloud, and most security dashboards. Sencho generates SARIF 2.1.0 documents from the stored scan results, so the download matches what you see in the drawer (same findings, same suppression state) without re-running Trivy.
From the scan drawer header, click **SARIF** to download the report. The file is named after the image reference with a `.sarif.json` extension.
+1 -3
View File
@@ -21,7 +21,6 @@ import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'
import type { ScanSummary } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils';
@@ -326,7 +325,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'networks' | 'unmanaged'>('images');
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const { isPaid } = useLicense();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
@@ -1412,7 +1410,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
onClose={() => setInspectScanId(null)}
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin}
canExportSarif={isAdmin}
canCompare
canManageSuppressions={isAdmin}
/>
+10 -23
View File
@@ -9,7 +9,6 @@ import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMast
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 { useImageScan } from '@/hooks/useImageScan';
@@ -63,7 +62,6 @@ const MOBILE_MASTHEAD_TONE: Record<MastheadTone, { dot: Tone; word: StateWordCla
};
export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const isMobile = useIsMobile();
@@ -209,23 +207,17 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
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;
// The mobile tab strip mirrors the desktop tab IA, including the paid-only
// Policies tab when licensed, so every section stays reachable by scroll.
// The mobile tab strip mirrors the desktop tab IA, so every section stays
// reachable by scroll.
const mobileTabs: SecurityMobileTab[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'images', label: 'Images' },
{ value: 'compose', label: 'Compose risks' },
{ value: 'secrets', label: 'Secrets' },
...(isPaid ? [{ value: 'policies', label: 'Policies' } as const] : []),
{ value: 'policies', label: 'Policies' },
{ value: 'suppressions', label: 'Suppressions' },
{ value: 'history', label: 'History' },
{ value: 'scanner', label: 'Scanner setup' },
@@ -270,7 +262,6 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
onInspect={onInspect}
canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)}
isPaid={isPaid}
/>
</TabsContent>
@@ -301,11 +292,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</CapabilityGate>
</TabsContent>
{isPaid && (
<TabsContent value="policies">
<ScanPolicyManager />
</TabsContent>
)}
<TabsContent value="policies">
<ScanPolicyManager />
</TabsContent>
<TabsContent value="suppressions">
{isRemote ? (
@@ -344,7 +333,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
initialTab={inspectInitialTab}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin}
canExportSarif={isAdmin}
canCompare
canManageSuppressions={isAdmin}
/>
@@ -422,11 +411,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
<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" />
{isPaid && (
<TabsHighlightItem value="policies">
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
</TabsHighlightItem>
)}
<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>
@@ -31,7 +31,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
mfaEnabled: null,
ssoEnabled: false,
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
scanPolicies: { total: 0, enabled: 0, locked: false },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -71,7 +71,8 @@ describe('ConfigurationStatus row visibility', () => {
expect(screen.queryByText('Notification routing')).toBeNull();
expect(screen.queryByText('Webhooks')).toBeNull();
expect(screen.queryByText('Scheduled tasks')).toBeNull();
expect(screen.queryByText('Vulnerability scanning')).toBeNull();
// Scan policies are free, so the Vulnerability scanning row renders.
expect(screen.getByText('Vulnerability scanning')).toBeDefined();
// Cloud Backup row is universal (Custom S3 is open to every tier).
expect(screen.getByText('Cloud Backup')).toBeDefined();
});
@@ -49,7 +49,7 @@ beforeEach(() => {
mfaEnabled: null,
ssoEnabled: false,
ssoProvider: null,
scanPolicies: { total: 0, enabled: 0, locked: true },
scanPolicies: { total: 0, enabled: 0, locked: false },
},
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -35,8 +35,6 @@ 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> = {
@@ -131,7 +129,7 @@ function ReviewQueueCard({
);
}
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
const isMobile = useIsMobile();
if (loadError === 'unsupported') {
@@ -293,7 +291,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
tone="subtitle"
/>
<p className="mt-2 text-xs text-muted-foreground">
{isPaid ? 'Manage enforcement policies on the Policies tab. ' : ''}This is a read-only posture for the active node.
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
</p>
</div>
</div>
@@ -14,7 +14,6 @@ import { SettingsCallout } from '@/components/settings/SettingsCallout';
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
@@ -53,14 +52,11 @@ const EMPTY_FORM: PolicyFormState = {
/**
* 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.
* is the governance surface for the Security page Policies tab. 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';
@@ -103,17 +99,17 @@ export function ScanPolicyManager() {
};
useEffect(() => {
if (!isPaid || isRemote) { setLoading(false); return; }
if (isRemote) { setLoading(false); return; }
fetchPolicies();
}, [isPaid, isRemote]);
}, [isRemote]);
useEffect(() => {
if (!isPaid || isRemote) return;
if (isRemote) return;
void refreshTrivy();
}, [isPaid, isRemote, activeNode?.id, refreshTrivy]);
}, [isRemote, activeNode?.id, refreshTrivy]);
useEffect(() => {
if (!isPaid || isRemote) return;
if (isRemote) return;
let cancelled = false;
(async () => {
try {
@@ -135,7 +131,7 @@ export function ScanPolicyManager() {
}
})();
return () => { cancelled = true; };
}, [isPaid, isRemote]);
}, [isRemote]);
const handleHonorSuppressionsToggle = async (enabled: boolean) => {
setHonorBusy(true);
@@ -264,11 +260,6 @@ export function ScanPolicyManager() {
}
};
// 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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
@@ -1,15 +1,13 @@
/**
* 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".
* ScanPolicyManager is the deploy-enforcement surface on the Security Policies
* tab. Key guard: 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, fireEvent, within } from '@testing-library/react';
import { toast } from '@/components/ui/toast-store';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/context/LicenseContext');
vi.mock('@/context/AuthContext');
vi.mock('@/context/NodeContext');
vi.mock('@/hooks/useTrivyStatus');
@@ -18,7 +16,6 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
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';
@@ -30,8 +27,7 @@ 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>);
function setup() {
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({
@@ -50,14 +46,8 @@ beforeEach(() => {
);
});
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 });
setup();
mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(500, {})),
);
@@ -67,7 +57,7 @@ it('surfaces an error state when the policies fetch fails (no false "no policies
});
it('shows the empty state when there are genuinely no policies', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument());
});
@@ -80,7 +70,7 @@ const riskPolicy = {
};
it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
setup({ isPaid: true });
setup();
mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])),
);
@@ -92,7 +82,7 @@ it('renders a per-input badge for each active input (KEV/Fixable, no severity)',
});
it('sends the risk-first defaults (KEV + fixable on, severity off) when creating a policy', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy'));
@@ -108,7 +98,7 @@ it('sends the risk-first defaults (KEV + fixable on, severity off) when creating
});
it('blocks a save that turns on block-on-deploy with no active input', async () => {
setup({ isPaid: true });
setup();
render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy'));
@@ -12,7 +12,6 @@ import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import type { CveSuppression } from '@/types/security';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8;
@@ -39,7 +38,6 @@ interface SuppressionsPanelProps {
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -205,12 +203,10 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
action={
isAdmin && !isReplica ? (
<div className="flex items-center gap-2">
{isPaid && (
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
)}
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
@@ -28,10 +28,6 @@ vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: false }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SuppressionsPanel } from '../SuppressionsPanel';
@@ -21,7 +21,7 @@ interface PreDeployScanDialogProps {
* Advisory pre-deploy review. Shows the latest cached scan for each image in a
* manual deploy so the operator can review the security posture before
* proceeding. Unlike PolicyBlockDialog this never blocks: anyone can deploy or
* cancel, and there is no override gate (blocking is the paid deploy-block
* cancel, and there is no override gate (blocking is the deploy-block
* policy). Opened opt-in via the pre-deploy scan advisory setting.
*/
export function PreDeployScanDialog({ open, stackName, images, onCancel, onDeploy }: PreDeployScanDialogProps) {