From 0947da13ae9280ff41026d5b803be7e0ffe20002 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 23 May 2026 01:18:30 -0400 Subject: [PATCH] fix(security): collapse repeated trivy-missing pre-deploy notifications (#1166) Scan policies on a node without Trivy installed previously fired one "Pre-deploy scan skipped" warning per deploy, flooding the notification feed during CI loops. Add a 60-minute per-(node, stack) cooldown so an operator sees one actionable warning, not one per deploy. The boot log line and the one-click managed install in Settings > Security are unchanged; this only reshapes the per-deploy fanout. Also tighten the vulnerability-scanning entry in /docs/features/overview to point first-touch users at the one-click install on first use. --- .../src/__tests__/policy-enforcement.test.ts | 70 +++++++++++++++++-- backend/src/services/PolicyEnforcement.ts | 38 ++++++---- docs/features/overview.mdx | 2 +- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index 763acdbf..7cc57d0b 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -1,7 +1,7 @@ /** - * Covers the pre-deploy policy gate across the six behaviours defined in the - * PR 1 plan: no-policy, disabled-policy, Trivy-missing (fail open), - * violation, admin bypass (audit-logged), compose-parse-failure (fail closed). + * Covers the pre-deploy policy gate: no-policy, disabled-policy, Trivy-missing + * (fail open + notification de-dup), violation, admin bypass (audit-logged), + * and compose-parse-failure (fail closed) paths. * * The gate is the only code path that can block a `docker compose up`, so * regressions here are high-impact. We stub the four collaborators the helper @@ -57,7 +57,11 @@ vi.mock('../services/FleetSyncService', () => ({ FleetSyncService: { getSelfIdentity: () => 'self-node' }, })); -import { enforcePolicyForImageRefs, enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; +import { + _resetTrivyMissingNotificationStateForTests, + enforcePolicyForImageRefs, + enforcePolicyPreDeploy, +} from '../services/PolicyEnforcement'; function mkPolicy(overrides: Partial = {}): ScanPolicy { return { @@ -114,6 +118,7 @@ describe('enforcePolicyPreDeploy', () => { dbStub.getMatchingPolicy.mockReset(); dbStub.insertAuditLog.mockReset(); notificationStub.dispatchAlert.mockReset(); + _resetTrivyMissingNotificationStateForTests(); }); it('allows deploy when no matching policy exists', async () => { @@ -181,6 +186,63 @@ describe('enforcePolicyPreDeploy', () => { expect(composeStub.listStackImages).not.toHaveBeenCalled(); }); + it('collapses repeated trivy-missing notifications for the same stack within the cooldown', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + + for (let i = 0; i < 5; i++) { + const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + expect(result.trivyMissing).toBe(true); + } + + expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(1); + }); + + it('dispatches a separate trivy-missing notification per stack', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('db', 1, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('db', 1, { bypass: false, actor: 'u' }); + + expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(2); + const stackNames = notificationStub.dispatchAlert.mock.calls.map((c) => (c[3] as { stackName: string }).stackName); + expect(stackNames).toEqual(['web', 'db']); + }); + + it('dispatches a separate trivy-missing notification per node for the same stack name', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('web', 2, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + await enforcePolicyPreDeploy('web', 2, { bypass: false, actor: 'u' }); + + expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(2); + }); + + it('redispatches the trivy-missing notification after the cooldown elapses', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + const nowSpy = vi.spyOn(Date, 'now'); + const start = 1_700_000_000_000; + nowSpy.mockReturnValue(start); + + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + nowSpy.mockReturnValue(start + 30 * 60 * 1000); + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(start + 60 * 60 * 1000 + 1); + await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); + it('blocks deploy when a scanned image exceeds the policy severity', async () => { dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); trivyStub.isTrivyAvailable.mockReturnValue(true); diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 6640be6e..240d2593 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -50,6 +50,30 @@ export interface PolicyEnforcementResult { trivyMissing?: boolean; } +const TRIVY_MISSING_NOTIFY_COOLDOWN_MS = 60 * 60 * 1000; +// Growth bounded by configured-policy fanout (only stacks with an enabled +// block_on_deploy policy can land here), not by total stack churn. Cleared +// on process restart, which is the right scope for an informational warning. +const trivyMissingNotifiedAt = new Map(); + +function notifyTrivyMissingOnce(nodeId: number, stackName: string): void { + const key = `${nodeId}:${stackName}`; + const now = Date.now(); + const last = trivyMissingNotifiedAt.get(key); + if (last !== undefined && now - last < TRIVY_MISSING_NOTIFY_COOLDOWN_MS) return; + trivyMissingNotifiedAt.set(key, now); + NotificationService.getInstance().dispatchAlert( + 'warning', + 'scan_finding', + `Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`, + { stackName }, + ); +} + +export function _resetTrivyMissingNotificationStateForTests(): void { + trivyMissingNotifiedAt.clear(); +} + export async function enforcePolicyPreDeploy( stackName: string, nodeId: number, @@ -68,12 +92,7 @@ export async function enforcePolicyPreDeploy( const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { - NotificationService.getInstance().dispatchAlert( - 'warning', - 'scan_finding', - `Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`, - { stackName }, - ); + notifyTrivyMissingOnce(nodeId, stackName); return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true }; } @@ -117,12 +136,7 @@ export async function enforcePolicyForImageRefs( const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { - NotificationService.getInstance().dispatchAlert( - 'warning', - 'scan_finding', - `Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`, - { stackName }, - ); + notifyTrivyMissingOnce(nodeId, stackName); return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true }; } diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index f3dfacb7..b9d6ebad 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 Skipper. [Learn more →](/features/vulnerability-scanning) +Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. 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