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'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
expect(res.status).toBe(200); 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.notifications.routingRules.locked).toBe(false);
expect(res.body.automation.webhooks.locked).toBe(false); expect(res.body.automation.webhooks.locked).toBe(false);
expect(res.body.automation.scheduledTasks.locked).toBe(false); expect(res.body.automation.scheduledTasks.locked).toBe(false);
// Scan policies stay paid-gated. expect(res.body.security.scanPolicies.locked).toBe(false);
expect(res.body.security.scanPolicies.locked).toBe(true);
}); });
it('unlocks every gated row for the paid tier', async () => { 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 }); 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 * 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 * 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 * toward a block-on-deploy policy. It must be reachable by any admin on every
* paid (Admiral) tier, matching the trivy-auto-update toggle. * tier; only the admin role is required.
*/ */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest'; import request from 'supertest';
@@ -52,7 +52,7 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
expect(res.status).toBe(403); 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 { LicenseService } = await import('../services/LicenseService');
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try { try {
@@ -60,7 +60,8 @@ describe('PUT /api/security/deploy-block-honor-suppressions', () => {
.put('/api/security/deploy-block-honor-suppressions') .put('/api/security/deploy-block-honor-suppressions')
.set('Cookie', adminCookie) .set('Cookie', adminCookie)
.send({ enabled: true }); .send({ enabled: true });
expect(res.status).toBe(403); expect(res.status).toBe(200);
expect(res.body.honorSuppressionsOnDeploy).toBe(true);
} finally { } finally {
spy.mockReturnValue('paid'); 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(() => { beforeEach(() => {
resetSecurity(); resetSecurity();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); 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'); 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); 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); expect(res.status).toBe(403);
}); });
it('exports an OpenVEX document from triage decisions for Admiral', async () => { it('exports an OpenVEX document from triage decisions', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
db().createCveSuppression({ db().createCveSuppression({
cve_id: 'CVE-2024-2222', pkg_name: null, image_pattern: 'nginx*', reason: 'not present in build', 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, 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: * Both scan-export endpoints are available on every tier (admin only, no tier gate):
* POST /api/security/sbom -> Community (admin only, no tier gate) * POST /api/security/sbom -> per-image SBOM artifact
* GET /api/security/scans/:id/sarif -> Admiral (paid) only * GET /api/security/scans/:id/sarif -> SARIF for CI / code-scanning ingestion
*
* SBOM is a per-image artifact useful to any self-hoster; SARIF (CI/security
* pipeline ingestion) stays a paid governance export.
*/ */
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest'; 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'); }); 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'); 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) const res = await request(app)
.get('/api/security/scans/999999/sarif') .get('/api/security/scans/999999/sarif')
.set('Cookie', adminCookie); .set('Cookie', adminCookie);
expect(res.status).not.toBe(403); expect(res.status).not.toBe(403);
expect(res.status).toBe(404); 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, tier: LicenseTier,
): ConfigurationStatus { ): ConfigurationStatus {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
const isPaid = tier === 'paid';
const agents = db.getAgents(nodeId); const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => { const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
@@ -129,11 +128,11 @@ export function buildLocalConfigurationStatus(
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null, mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
ssoEnabled: !!enabledSso, ssoEnabled: !!enabledSso,
ssoProvider: enabledSso?.provider ?? null, ssoProvider: enabledSso?.provider ?? null,
// Scan policies (deploy enforcement) require a paid license. // Scan policies are available on every tier.
scanPolicies: { scanPolicies: {
total: scanPolicies.length, total: scanPolicies.length,
enabled: scanPolicies.filter(p => p.enabled === 1).length, enabled: scanPolicies.filter(p => p.enabled === 1).length,
locked: !isPaid, locked: false,
}, },
}, },
thresholds: { thresholds: {
+6 -12
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express'; import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates'; import { requireAdmin } from '../middleware/tierGates';
import { trivyInstallLimiter } from '../middleware/rateLimiters'; import { trivyInstallLimiter } from '../middleware/rateLimiters';
import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyService, { SbomFormat } from '../services/TrivyService';
import TrivyInstaller from '../services/TrivyInstaller'; 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. // deploys, against that node's own replicated suppression copy. Default off.
securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Request, res: Response): void => { securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
// Require an explicit boolean so a stringy `"1"` cannot silently disable the // Require an explicit boolean so a stringy `"1"` cannot silently disable the
// gate (this toggle weakens a deploy block, so intent must be unambiguous). // gate (this toggle weakens a deploy block, so intent must be unambiguous).
if (typeof req.body?.enabled !== 'boolean') { 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 // 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 // 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. // deploy-block policy). Per-instance, admin-only, all tiers. Default off.
securityRouter.put('/pre-deploy-scan-advisory', authMiddleware, (req: Request, res: Response): void => { securityRouter.put('/pre-deploy-scan-advisory', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
@@ -1031,7 +1030,6 @@ securityRouter.get(
authMiddleware, authMiddleware,
(req: Request, res: Response): void => { (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId); const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) { if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return; 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 // Export the instance's CVE triage decisions as an OpenVEX document. Admin-only,
// fleet VEX is a governance feature, so it is Admiral (paid) + admin, mirroring // mirroring the SARIF export.
// the SARIF export gate.
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => { securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try { try {
const suppressions = DatabaseService.getInstance().getCveSuppressions(); const suppressions = DatabaseService.getInstance().getCveSuppressions();
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString()); 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 => { 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 // Replicas see only policies that apply to themselves: local-only rows plus
// fleet-wide and self-identity-matched replicated rows. Identity-scoped // fleet-wide and self-identity-matched replicated rows. Identity-scoped
// rows targeting other replicas are filtered out at the SQL boundary. // 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 => { securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) 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 ?? {}; 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()) { 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 => { securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return; if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isFinite(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 => { securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return; if (blockIfReplica(res, 'security policies')) return;
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
+1 -1
View File
@@ -116,7 +116,7 @@ Some endpoints are gated by license tier:
| Tier | Gated features | | 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. 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. 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 ## Scan policies
@@ -17,7 +17,7 @@ Writes are admin-only and rejected on replica nodes (policies are managed on the
**`GET /api/security/policies`** **`GET /api/security/policies`**
**License:** Admiral **License:** Community
```bash ```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \ curl -H "Authorization: Bearer YOUR_API_TOKEN" \
@@ -51,7 +51,7 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \
**`POST /api/security/policies`** **`POST /api/security/policies`**
**License:** Admiral · **Role:** Admin **License:** Community · **Role:** Admin
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|:--------:|-------------| |-------|------|:--------:|-------------|
@@ -93,7 +93,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/policies \
**`PUT /api/security/policies/{id}`** **`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. 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}`** **`DELETE /api/security/policies/{id}`**
**License:** Admiral · **Role:** Admin **License:** Community · **Role:** Admin
```bash ```bash
curl -X DELETE https://your-sencho-instance:1852/api/security/policies/1 \ 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. 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 ## 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 | | **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 | | **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 ### 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. 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 ## 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. 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 | | 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. | | **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. | | **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. | | **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: **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 - **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) - **Fleet operations:** node cordon, Blueprints, and Sencho Mesh (cross-node container networking)
- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance) - **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance)
- **Operator access:** the Host Console (a browser-based terminal on the Sencho host) - **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 ### 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 ### Blueprints
@@ -159,7 +159,7 @@ Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflow
### Vulnerability scanning ### 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 ### CVE suppressions
@@ -201,4 +201,4 @@ When you manage multiple nodes running different Sencho versions, the dashboard
### Licensing & billing ### 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 ## Policies
The Policies tab manages deploy-enforcement scan policies: severity thresholds that block or warn on a 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. [scan policies](/features/vulnerability-scanning#scan-policies) for the full configuration.
## Suppressions ## 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, 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 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 action posture; a "needs review" decision stays counted but keeps the finding actionable. You can
instances can export the fleet's triage decisions as an OpenVEX document for use with other tooling. export the fleet's triage decisions as an OpenVEX document for use with other tooling.
## History ## 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." 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> <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" /> <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. 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> </Note>
## Tier availability ## What's included
| Feature | Community | Admiral | Every vulnerability-scanning capability is available on every tier:
|---------|:---------:|:-------:|
| Install / update / uninstall Trivy | ✓ | ✓ | - Install / update / uninstall Trivy, with auto-update of the managed binary
| On-demand image vulnerability scanning | ✓ | ✓ | - On-demand image scanning and full scans (vulnerabilities + secrets)
| Full scan (vulnerabilities + secrets) | ✓ | ✓ | - Compose file misconfiguration scanning
| Compose file misconfiguration scanning | ✓ | ✓ | - Severity badges in the Resources Hub and a scan results drawer with grouped tabs
| Severity badges in the Resources Hub | ✓ | ✓ | - Post-deploy automated scanning and a pre-deploy scan advisory on manual deploys
| Scan results drawer with grouped tabs | ✓ | ✓ | - Scan history sheet and scan comparison
| Post-deploy automated scanning | ✓ | ✓ | - CVE suppressions and misconfig acknowledgements
| Pre-deploy scan advisory on manual deploys | ✓ | ✓ | - Scheduled fleet scans (all images on a node)
| Scan history sheet | ✓ | ✓ | - Scan policies with `block_on_deploy` enforcement, including suppression-aware deploy blocking
| Scan comparison | ✓ | ✓ | - SBOM generation (SPDX, CycloneDX) and SARIF export for code-scanning integration
| 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 | ✓ | ✓ |
## On-demand scanning ## 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. - **Re-scan**: kick off a fresh scan, ignoring the digest cache.
- **Compare**: pick a baseline scan from the dropdown to diff against this one. - **Compare**: pick a baseline scan from the dropdown to diff against this one.
- **CSV**: export the full vulnerability list for offline review. - **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. 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 ## 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. 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. 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 ## 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. 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. 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 type { ScanSummary } from '@/types/security';
import { useNodes } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { CapabilityGate } from './CapabilityGate'; import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary'; import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils'; 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 [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'networks' | 'unmanaged'>('images');
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { activeNode } = useNodes(); const { activeNode } = useNodes();
const { isPaid } = useLicense();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list'); const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null); const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]); const [images, setImages] = useState<DockerImage[]>([]);
@@ -1412,7 +1410,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
onClose={() => setInspectScanId(null)} onClose={() => setInspectScanId(null)}
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
canGenerateSbom={isAdmin} canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin} canExportSarif={isAdmin}
canCompare canCompare
canManageSuppressions={isAdmin} canManageSuppressions={isAdmin}
/> />
+10 -23
View File
@@ -9,7 +9,6 @@ import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMast
import { springs } from '@/lib/motion'; import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime'; import { formatTimeAgo } from '@/lib/relativeTime';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext';
import { useImageScan } from '@/hooks/useImageScan'; 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) { export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { activeNode } = useNodes(); const { activeNode } = useNodes();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -209,23 +207,17 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isRemote, activeNode?.id]); }, [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 { state, tone } = deriveMasthead(overview, overviewLoadError !== null);
const pulsing = tone === 'live' && !!overview?.scanner.available; const pulsing = tone === 'live' && !!overview?.scanner.available;
// The mobile tab strip mirrors the desktop tab IA, including the paid-only // The mobile tab strip mirrors the desktop tab IA, so every section stays
// Policies tab when licensed, so every section stays reachable by scroll. // reachable by scroll.
const mobileTabs: SecurityMobileTab[] = [ const mobileTabs: SecurityMobileTab[] = [
{ value: 'overview', label: 'Overview' }, { value: 'overview', label: 'Overview' },
{ value: 'images', label: 'Images' }, { value: 'images', label: 'Images' },
{ value: 'compose', label: 'Compose risks' }, { value: 'compose', label: 'Compose risks' },
{ value: 'secrets', label: 'Secrets' }, { value: 'secrets', label: 'Secrets' },
...(isPaid ? [{ value: 'policies', label: 'Policies' } as const] : []), { value: 'policies', label: 'Policies' },
{ value: 'suppressions', label: 'Suppressions' }, { value: 'suppressions', label: 'Suppressions' },
{ value: 'history', label: 'History' }, { value: 'history', label: 'History' },
{ value: 'scanner', label: 'Scanner setup' }, { value: 'scanner', label: 'Scanner setup' },
@@ -270,7 +262,6 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
onInspect={onInspect} onInspect={onInspect}
canScan={canScan} canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)} onScanComplete={() => setReloadToken((t) => t + 1)}
isPaid={isPaid}
/> />
</TabsContent> </TabsContent>
@@ -301,11 +292,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</CapabilityGate> </CapabilityGate>
</TabsContent> </TabsContent>
{isPaid && ( <TabsContent value="policies">
<TabsContent value="policies"> <ScanPolicyManager />
<ScanPolicyManager /> </TabsContent>
</TabsContent>
)}
<TabsContent value="suppressions"> <TabsContent value="suppressions">
{isRemote ? ( {isRemote ? (
@@ -344,7 +333,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
initialTab={inspectInitialTab} initialTab={inspectInitialTab}
onClose={() => setInspectScanId(null)} onClose={() => setInspectScanId(null)}
canGenerateSbom={isAdmin} canGenerateSbom={isAdmin}
canExportSarif={isPaid && isAdmin} canExportSarif={isAdmin}
canCompare canCompare
canManageSuppressions={isAdmin} 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> <TabsTrigger value="secrets"><KeyRound className="w-4 h-4 mr-1.5" />Secrets</TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" /> <span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && ( <TabsHighlightItem value="policies">
<TabsHighlightItem value="policies"> <TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger> </TabsHighlightItem>
</TabsHighlightItem>
)}
<TabsHighlightItem value="suppressions"> <TabsHighlightItem value="suppressions">
<TabsTrigger value="suppressions"><EyeOff className="w-4 h-4 mr-1.5" />Suppressions</TabsTrigger> <TabsTrigger value="suppressions"><EyeOff className="w-4 h-4 mr-1.5" />Suppressions</TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
@@ -31,7 +31,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
mfaEnabled: null, mfaEnabled: null,
ssoEnabled: false, ssoEnabled: false,
ssoProvider: null, 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 }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false }, backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -71,7 +71,8 @@ describe('ConfigurationStatus row visibility', () => {
expect(screen.queryByText('Notification routing')).toBeNull(); expect(screen.queryByText('Notification routing')).toBeNull();
expect(screen.queryByText('Webhooks')).toBeNull(); expect(screen.queryByText('Webhooks')).toBeNull();
expect(screen.queryByText('Scheduled tasks')).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). // Cloud Backup row is universal (Custom S3 is open to every tier).
expect(screen.getByText('Cloud Backup')).toBeDefined(); expect(screen.getByText('Cloud Backup')).toBeDefined();
}); });
@@ -49,7 +49,7 @@ beforeEach(() => {
mfaEnabled: null, mfaEnabled: null,
ssoEnabled: false, ssoEnabled: false,
ssoProvider: null, 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 }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
backup: { provider: 'disabled', autoUpload: false, locked: false }, backup: { provider: 'disabled', autoUpload: false, locked: false },
@@ -35,8 +35,6 @@ interface OverviewTabProps {
canScan: boolean; canScan: boolean;
/** Refresh the overview after a node-wide scan completes. */ /** Refresh the overview after a node-wide scan completes. */
onScanComplete: () => void; 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> = { 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(); const isMobile = useIsMobile();
if (loadError === 'unsupported') { if (loadError === 'unsupported') {
@@ -293,7 +291,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
tone="subtitle" tone="subtitle"
/> />
<p className="mt-2 text-xs text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@@ -14,7 +14,6 @@ import { SettingsCallout } from '@/components/settings/SettingsCallout';
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions'; import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
import { useNodes } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; 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 * Deploy-enforcement scan policies (block-on-deploy severity thresholds), the
* honor-suppressions toggle, and the replica "managed by control" state. This * honor-suppressions toggle, and the replica "managed by control" state. This
* is the paid governance surface for the Security page Policies tab; it returns * is the governance surface for the Security page Policies tab. Policies are
* null for Community (no enforcement management) so the catalog is all a * control-governed: fetched localOnly and shown only on the local node,
* Community operator sees. Policies are control-governed: fetched localOnly and * mirroring how the rest of the fleet-governance UI behaves.
* shown only on the local node, mirroring how the rest of the fleet-governance
* UI behaves.
*/ */
export function ScanPolicyManager() { export function ScanPolicyManager() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { activeNode } = useNodes(); const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote'; const isRemote = activeNode?.type === 'remote';
@@ -103,17 +99,17 @@ export function ScanPolicyManager() {
}; };
useEffect(() => { useEffect(() => {
if (!isPaid || isRemote) { setLoading(false); return; } if (isRemote) { setLoading(false); return; }
fetchPolicies(); fetchPolicies();
}, [isPaid, isRemote]); }, [isRemote]);
useEffect(() => { useEffect(() => {
if (!isPaid || isRemote) return; if (isRemote) return;
void refreshTrivy(); void refreshTrivy();
}, [isPaid, isRemote, activeNode?.id, refreshTrivy]); }, [isRemote, activeNode?.id, refreshTrivy]);
useEffect(() => { useEffect(() => {
if (!isPaid || isRemote) return; if (isRemote) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -135,7 +131,7 @@ export function ScanPolicyManager() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isPaid, isRemote]); }, [isRemote]);
const handleHonorSuppressionsToggle = async (enabled: boolean) => { const handleHonorSuppressionsToggle = async (enabled: boolean) => {
setHonorBusy(true); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -1,15 +1,13 @@
/** /**
* ScanPolicyManager is the paid deploy-enforcement surface on the Security * ScanPolicyManager is the deploy-enforcement surface on the Security Policies
* Policies tab. Key guards: it renders nothing for Community, and a failed * tab. Key guard: a failed policy fetch surfaces an error state instead of a
* policy fetch surfaces an error state instead of a false "No scan policies * false "No scan policies configured".
* configured".
*/ */
import { it, expect, vi, beforeEach } from 'vitest'; import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/context/LicenseContext');
vi.mock('@/context/AuthContext'); vi.mock('@/context/AuthContext');
vi.mock('@/context/NodeContext'); vi.mock('@/context/NodeContext');
vi.mock('@/hooks/useTrivyStatus'); vi.mock('@/hooks/useTrivyStatus');
@@ -18,7 +16,6 @@ vi.mock('@/components/ui/toast-store', () => ({
})); }));
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import * as LicenseContext from '@/context/LicenseContext';
import * as AuthContext from '@/context/AuthContext'; import * as AuthContext from '@/context/AuthContext';
import * as NodeContext from '@/context/NodeContext'; import * as NodeContext from '@/context/NodeContext';
import * as TrivyStatus from '@/hooks/useTrivyStatus'; 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; return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
} }
function setup({ isPaid }: { isPaid: boolean }) { function setup() {
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(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(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({ 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 () => { it('surfaces an error state when the policies fetch fails (no false "no policies")', async () => {
setup({ isPaid: true }); setup();
mockedFetch.mockImplementation((url: string) => mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(500, {})), 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 () => { it('shows the empty state when there are genuinely no policies', async () => {
setup({ isPaid: true }); setup();
render(<ScanPolicyManager />); render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument()); 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 () => { it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
setup({ isPaid: true }); setup();
mockedFetch.mockImplementation((url: string) => mockedFetch.mockImplementation((url: string) =>
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])), 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 () => { it('sends the risk-first defaults (KEV + fixable on, severity off) when creating a policy', async () => {
setup({ isPaid: true }); setup();
render(<ScanPolicyManager />); render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy')); 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 () => { it('blocks a save that turns on block-on-deploy with no active input', async () => {
setup({ isPaid: true }); setup();
render(<ScanPolicyManager />); render(<ScanPolicyManager />);
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add policy')); fireEvent.click(screen.getByText('Add policy'));
@@ -12,7 +12,6 @@ import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState'; import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import type { CveSuppression } from '@/types/security'; import type { CveSuppression } from '@/types/security';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8; const PAGE_SIZE = 8;
@@ -39,7 +38,6 @@ interface SuppressionsPanelProps {
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const [rows, setRows] = useState<CveSuppression[]>([]); const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -205,12 +203,10 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
action={ action={
isAdmin && !isReplica ? ( isAdmin && !isReplica ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isPaid && ( <Button size="sm" variant="outline" onClick={handleExportVex}>
<Button size="sm" variant="outline" onClick={handleExportVex}> <Download className="w-4 h-4 mr-1.5" />
<Download className="w-4 h-4 mr-1.5" /> Export VEX
Export VEX </Button>
</Button>
)}
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" /> <Plus className="w-4 h-4 mr-1.5" />
Add suppression Add suppression
@@ -28,10 +28,6 @@ vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }), useAuth: () => ({ isAdmin: true }),
})); }));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: false }),
}));
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { SuppressionsPanel } from '../SuppressionsPanel'; import { SuppressionsPanel } from '../SuppressionsPanel';
@@ -21,7 +21,7 @@ interface PreDeployScanDialogProps {
* Advisory pre-deploy review. Shows the latest cached scan for each image in a * 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 * manual deploy so the operator can review the security posture before
* proceeding. Unlike PolicyBlockDialog this never blocks: anyone can deploy or * 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. * policy). Opened opt-in via the pre-deploy scan advisory setting.
*/ */
export function PreDeployScanDialog({ open, stackName, images, onCancel, onDeploy }: PreDeployScanDialogProps) { export function PreDeployScanDialog({ open, stackName, images, onCancel, onDeploy }: PreDeployScanDialogProps) {