mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough.
|
||||
## Security
|
||||
|
||||
<Note>
|
||||
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).
|
||||
</Note>
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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<number | null>(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 }) {
|
||||
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
|
||||
)}
|
||||
|
||||
{trivy.source === 'managed' && isAdmiral && (
|
||||
{trivy.source === 'managed' && isPaid && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Auto-update Trivy</Label>
|
||||
|
||||
Reference in New Issue
Block a user