From 8a3889dc67c2d61d2e539ac5b8778bd81c558d52 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 21 May 2026 23:54:01 -0400 Subject: [PATCH] feat(security): move managed Trivy auto-update to Skipper tier (#1150) * feat(security): move managed Trivy auto-update to Skipper tier Drop the gate on the managed Trivy auto-update toggle from Admiral to Skipper so it lives alongside the rest of Sencho's automation features (auto-heal, scheduled ops, per-stack image auto-update, scan policies) instead of behind the enterprise-control tier. Backend: `PUT /api/security/trivy-auto-update` switches from `requireAdmiral` to `requirePaid`. The 24h scheduler tick in SchedulerService that reads the setting is tier-neutral and picks the new gate up automatically. Frontend: SecuritySection.tsx swaps the inline `isAdmiral` conditional on the toggle render for `isPaid`. The local `isAdmiral` derivation and the `useLicense` import become unused and are removed. Docs: licensing, overview, vulnerability-scanning matrix, trivy-setup, and the settings reference now read Skipper consistently for this feature. * fix(security): require admin role on trivy-auto-update toggle Independent audit of the prior commit flagged that PUT /api/security/trivy-auto-update had no admin-role guard. The route was authenticated and tier-gated, but the global /api authGate only authenticates and `requirePaid` only checks tier. Any paid viewer could flip the global trivy_auto_update setting via a direct API call. Add `requireAdmin` ahead of `requirePaid`, matching the pattern used by every other mutating route in this file (sbom, policies, suppressions, misconfig-acks). Add route tests covering paid admin allowed, paid viewer rejected, community admin rejected, and unauthenticated rejected. --- .../security-trivy-auto-update-route.test.ts | 89 +++++++++++++++++++ backend/src/routes/security.ts | 5 +- docs/features/licensing.mdx | 2 +- docs/features/overview.mdx | 2 +- docs/features/vulnerability-scanning.mdx | 2 +- docs/operations/trivy-setup.mdx | 2 +- docs/reference/settings.mdx | 4 +- .../components/settings/SecuritySection.tsx | 5 +- 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 backend/src/__tests__/security-trivy-auto-update-route.test.ts diff --git a/backend/src/__tests__/security-trivy-auto-update-route.test.ts b/backend/src/__tests__/security-trivy-auto-update-route.test.ts new file mode 100644 index 00000000..47433bff --- /dev/null +++ b/backend/src/__tests__/security-trivy-auto-update-route.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for the role + tier gate on PUT /api/security/trivy-auto-update. + * + * The route flips the global `trivy_auto_update` setting that the scheduler + * reads every 24h to decide whether to pull newer Trivy binary releases. + * It must be reachable only by an admin on a paid (Skipper or Admiral) tier. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass3', 1); + DatabaseService.getInstance().addUser({ username: 'trivy-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'trivy-viewer', password: 'viewerpass3' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('PUT /api/security/trivy-auto-update', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .put('/api/security/trivy-auto-update') + .send({ enabled: true }); + expect(res.status).toBe(401); + }); + + it('rejects authenticated viewer with 403', async () => { + const res = await request(app) + .put('/api/security/trivy-auto-update') + .set('Cookie', viewerCookie) + .send({ enabled: true }); + expect(res.status).toBe(403); + }); + + it('rejects Community tier with 403', async () => { + const { LicenseService } = await import('../services/LicenseService'); + const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + try { + const res = await request(app) + .put('/api/security/trivy-auto-update') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(403); + } finally { + spy.mockReturnValue('paid'); + } + }); + + it('accepts paid admin and persists the setting (enable)', async () => { + const res = await request(app) + .put('/api/security/trivy-auto-update') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.autoUpdate).toBe(true); + expect(DatabaseService.getInstance().getGlobalSettings().trivy_auto_update).toBe('1'); + }); + + it('accepts paid admin and persists the setting (disable)', async () => { + const res = await request(app) + .put('/api/security/trivy-auto-update') + .set('Cookie', adminCookie) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body.autoUpdate).toBe(false); + expect(DatabaseService.getInstance().getGlobalSettings().trivy_auto_update).toBe('0'); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index e12d887c..f0e1c33d 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, requireAdmiral, requirePaid } from '../middleware/tierGates'; +import { requireAdmin, requirePaid } from '../middleware/tierGates'; import { trivyInstallLimiter } from '../middleware/rateLimiters'; import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyInstaller from '../services/TrivyInstaller'; @@ -203,7 +203,8 @@ securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async }); securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmiral(req, res)) return; + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; const enabled = req.body?.enabled === true; try { DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0'); diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index a6734574..8dd3baf7 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -47,6 +47,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Auto-heal policies - Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune) - Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export +- Auto-update of the managed Trivy binary - Bulk actions on a label (deploy, stop, or restart every stack tagged with it) - Remote OTA node updates from the control instance - Fleet Actions (bulk update all, bulk restart Sencho) @@ -64,7 +65,6 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Private and custom registry credentials - Sencho Mesh (cross-node container networking) - Sencho Cloud Backup -- Auto-update of the managed Trivy binary ## Free trial diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index b725ae8a..b49d2b58 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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). Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and SARIF export are available on Skipper and Admiral. Auto-update of the managed Trivy binary is Admiral. [Learn more →](/features/vulnerability-scanning) +Scan container images for known CVEs with [Trivy](https://trivy.dev). Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and SARIF export are available on Skipper and Admiral. Auto-update of the managed Trivy binary is Skipper. [Learn more →](/features/vulnerability-scanning) ### CVE suppressions diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index eeeb4ac0..c1be89aa 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -36,7 +36,7 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | | SARIF export (code scanning integration) | | ✓ | ✓ | -| Auto-update of the managed Trivy binary | | | ✓ | +| Auto-update of the managed Trivy binary | | ✓ | ✓ | ## On-demand scanning diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 8cbaf4e5..57e5a41a 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -44,7 +44,7 @@ When a newer Trivy release is available, Settings → Security shows an **Update To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. -The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle is Admiral only. +The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle requires Skipper. ### Removing the managed install diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 8c056ba9..df16464e 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -388,7 +388,7 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough. ## Security - Security is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies require Sencho Skipper or Admiral; the **Auto-update Trivy** toggle requires Admiral and a managed Trivy binary. + Security is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies and the **Auto-update Trivy** toggle require Sencho Skipper or Admiral (the toggle also requires a managed Trivy binary). **Scope:** Per-node @@ -406,7 +406,7 @@ Manage the Trivy scanner, scan policies, suppressions, and acknowledgements that | **Status** | `Installed (managed)` when Sencho manages the binary, `Installed (host)` when an existing host binary is being reused, or empty when nothing is detected. | | **Version** | The current Trivy version, when installed. | | **Install / Update / Uninstall** | Lifecycle actions for the managed binary. Uninstall asks for confirmation. | -| **Auto-update Trivy** toggle | When on, Sencho checks daily and installs newer Trivy releases automatically. Requires Admiral and a managed Trivy binary. | +| **Auto-update Trivy** toggle | When on, Sencho checks daily and installs newer Trivy releases automatically. Requires Skipper and a managed Trivy binary. | ### Scan policies diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index 39ea7e81..ba84d38b 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -14,7 +14,6 @@ import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; -import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { SuppressionsPanel } from './SuppressionsPanel'; @@ -70,8 +69,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { const [saving, setSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); - const { license } = useLicense(); - const isAdmiral = isPaid && license?.variant === 'admiral'; const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); @@ -402,7 +399,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}
)} - {trivy.source === 'managed' && isAdmiral && ( + {trivy.source === 'managed' && isPaid && (