diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts index 1c3fdc8e..c14891f4 100644 --- a/backend/src/__tests__/dashboard-routes.test.ts +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/scan-policies-route.test.ts b/backend/src/__tests__/scan-policies-route.test.ts index a0ee162f..99b1e286 100644 --- a/backend/src/__tests__/scan-policies-route.test.ts +++ b/backend/src/__tests__/scan-policies-route.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts index e3acc673..03d0a2fe 100644 --- a/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts +++ b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts @@ -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'); } diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index 8d281f90..eafebc30 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -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, diff --git a/backend/src/__tests__/security-sbom-sarif-tier.test.ts b/backend/src/__tests__/security-sbom-sarif-tier.test.ts index 70061c24..6ecdc547 100644 --- a/backend/src/__tests__/security-sbom-sarif-tier.test.ts +++ b/backend/src/__tests__/security-sbom-sarif-tier.test.ts @@ -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); + }); }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 833638bd..5bc7e1b2 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -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: { diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index c3e25ca2..08865260 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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)) { diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index b9facd8f..23a0ef04 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -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. diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx index 5e0233dc..29b0c9fd 100644 --- a/docs/api-reference/security.mdx +++ b/docs/api-reference/security.mdx @@ -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 \ diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 58dfe6dc..4587f9d0 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -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 diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 413eceb2..f7a842e2 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -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 diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index 218a04d6..5efdacf6 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -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 and scan policies require an **Admiral** license. - - ## 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: ` when the severity threshold is on), a `block` badge, the configured stack-pattern scope, and pencil and trash buttons. diff --git a/docs/features/fleet-sync.mdx b/docs/features/fleet-sync.mdx index 086c5f26..8ef30e28 100644 --- a/docs/features/fleet-sync.mdx +++ b/docs/features/fleet-sync.mdx @@ -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. | diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 3711b75f..2af62443 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -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) diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 3c999d5e..1c8e1810 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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) diff --git a/docs/features/security.mdx b/docs/features/security.mdx index 0c3eb788..f8a3ee96 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -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 diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index b0b2a5ba..456cac91 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -3,7 +3,7 @@ title: "Vulnerability Scanning" description: "Scan container images and stack compose files for CVEs, secrets, and misconfigurations. Surface severity badges in the Resources Hub, compare scans over time, and gate deploys on policy violations." --- -Sencho integrates with [Trivy](https://trivy.dev) to scan container images and Compose files for vulnerabilities (CVEs), hardcoded secrets, and misconfigurations. Findings surface as severity badges in the Resources Hub and as drillable reports in the scan drawer. 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. 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. -## 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 - - Scan policies require an **Admiral** license. - - 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 - - SARIF export requires an **Admiral** license. - - 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. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 2597ebf9..6e5dde6b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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(null); const [images, setImages] = useState([]); @@ -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} /> diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index c06b8351..de4fb61b 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -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 { 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} /> @@ -301,11 +292,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security - {isPaid && ( - - - - )} + + + {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 Secrets - {isPaid && ( - - Policies - - )} + + Policies + Suppressions diff --git a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx index f46b3f98..9ac390f7 100644 --- a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx @@ -31,7 +31,7 @@ function makePayload(overrides: Partial = {}): 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(); }); diff --git a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx index fed3c4fa..b1c932a3 100644 --- a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx @@ -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 }, diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx index facb136b..96def8a4 100644 --- a/frontend/src/components/security/OverviewTab.tsx +++ b/frontend/src/components/security/OverviewTab.tsx @@ -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" />

- {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.

diff --git a/frontend/src/components/security/ScanPolicyManager.tsx b/frontend/src/components/security/ScanPolicyManager.tsx index 5eedb59a..91d43308 100644 --- a/frontend/src/components/security/ScanPolicyManager.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -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 (
diff --git a/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx index 5242fd6f..2e074e44 100644 --- a/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx +++ b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx @@ -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); +function setup() { vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType); vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType); vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({ @@ -50,14 +46,8 @@ beforeEach(() => { ); }); -it('renders nothing for a Community operator (paid surface)', () => { - setup({ isPaid: false }); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); -}); - it('surfaces an error state when the policies fetch fails (no false "no policies")', async () => { - setup({ isPaid: true }); + 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(); 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(); 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(); await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument()); fireEvent.click(screen.getByText('Add policy')); diff --git a/frontend/src/components/settings/SuppressionsPanel.tsx b/frontend/src/components/settings/SuppressionsPanel.tsx index 37fed2ae..a7620369 100644 --- a/frontend/src/components/settings/SuppressionsPanel.tsx +++ b/frontend/src/components/settings/SuppressionsPanel.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); @@ -205,12 +203,10 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { action={ isAdmin && !isReplica ? (
- {isPaid && ( - - )} +