mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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> = {}): 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);
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user