From 5de59c3faf137a29a675058cc9ae79606308e882 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 30 Mar 2026 22:43:23 +0100 Subject: [PATCH] Demote low-risk security warnings out of the global banner --- .../internal/subsystems/security-privacy.md | 10 +++++ .../src/components/SecurityWarning.tsx | 16 ++++---- .../__tests__/SecurityWarning.test.tsx | 36 +++++++++++++---- .../securityScorePresentation.test.ts | 39 +++++++++++++++++++ .../src/utils/securityScorePresentation.ts | 21 ++++++++++ 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 0d5705e85..9042fb38a 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -182,6 +182,16 @@ only claim the instance is accessible without authentication when missing HTTPS, API tokens, or protected exports must not reuse the unauthenticated credential-exposure warning just because the aggregate score remains below the banner threshold. +That same shared runtime-warning boundary must also keep the global banner +reserved for active exposure states rather than generic setup debt: +`frontend-modern/src/components/SecurityWarning.tsx` and +`frontend-modern/src/utils/securityScorePresentation.ts` may surface an +always-visible app-wide warning when authentication is disabled, export +protection is disabled, or a publicly reachable instance is still serving over +HTTP, but private authenticated runtimes that are only missing optional +hardening controls such as HTTPS on localhost or an API token must route that +guidance through the governed Security Overview posture surfaces instead of +covering the primary app chrome with a persistent warning. That same shared security transport boundary must stay under explicit proof routing on both sides: `frontend-modern/src/api/security.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and diff --git a/frontend-modern/src/components/SecurityWarning.tsx b/frontend-modern/src/components/SecurityWarning.tsx index b7b20f972..259c28388 100644 --- a/frontend-modern/src/components/SecurityWarning.tsx +++ b/frontend-modern/src/components/SecurityWarning.tsx @@ -10,6 +10,7 @@ import { getSecurityScoreSymbol, getSecurityScoreTextClass, getSecurityWarningPresentation, + shouldShowGlobalSecurityWarning, } from '@/utils/securityScorePresentation'; import type { SecurityStatus } from '@/types/config'; @@ -94,15 +95,16 @@ export const SecurityWarning: Component = () => { // Show more aggressively if public access detected const shouldShow = () => { if (dismissed()) return false; - if (!status()) return false; - // Always show if public access without auth - if (status()!.publicAccess && !status()!.hasAuthentication) { - return true; - } + const currentStatus = status(); + if (!currentStatus) return false; - // Show if score is low - return status()!.score < 4; + return shouldShowGlobalSecurityWarning({ + hasAuthentication: currentStatus.hasAuthentication, + exportProtected: currentStatus.exportProtected, + hasHTTPS: currentStatus.hasHTTPS, + publicAccess: currentStatus.publicAccess, + }); }; const scorePercentage = () => (status()!.score / status()!.maxScore) * 100; diff --git a/frontend-modern/src/components/__tests__/SecurityWarning.test.tsx b/frontend-modern/src/components/__tests__/SecurityWarning.test.tsx index 01d8dbee7..3a025eb67 100644 --- a/frontend-modern/src/components/__tests__/SecurityWarning.test.tsx +++ b/frontend-modern/src/components/__tests__/SecurityWarning.test.tsx @@ -31,7 +31,7 @@ describe('SecurityWarning', () => { afterEach(cleanup); - it('renders after the async security status resolves to a low score', async () => { + it('does not render for private authenticated setup debt', async () => { const pendingStatus = deferred(); apiFetchJSONMock.mockReturnValue(pendingStatus.promise); @@ -43,25 +43,45 @@ describe('SecurityWarning', () => { pendingStatus.resolve({ apiTokenConfigured: false, credentialsEncrypted: true, - exportProtected: false, + exportProtected: true, hasAuditLogging: false, hasAuthentication: true, hasHTTPS: false, publicAccess: false, }); + await waitFor(() => { + expect(apiFetchJSONMock).toHaveBeenCalled(); + }); + + expect(screen.queryByText(/Security score:/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('renders for active exposure states', async () => { + const pendingStatus = deferred(); + apiFetchJSONMock.mockReturnValue(pendingStatus.promise); + + const { SecurityWarning } = await import('../SecurityWarning'); + render(() => ); + + pendingStatus.resolve({ + apiTokenConfigured: false, + credentialsEncrypted: true, + exportProtected: true, + hasAuditLogging: false, + hasAuthentication: false, + hasHTTPS: false, + publicAccess: true, + }); + await waitFor(() => { expect(screen.getByText(/Security score:/i)).toBeInTheDocument(); }); expect( - screen.getByText( - 'Authentication is enabled, but this Pulse instance is still missing HTTPS, an API token, and protected exports.', - ), + screen.getByText(/public network access detected/i), ).toBeInTheDocument(); - expect( - screen.queryByText(/accessible without authentication/i), - ).not.toBeInTheDocument(); const banner = screen.getByRole('status'); expect(banner).not.toHaveClass('fixed'); expect(screen.getByRole('link', { name: 'Learn More' })).toHaveAttribute( diff --git a/frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts b/frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts index f0fc4510e..e3929bf4e 100644 --- a/frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts @@ -9,6 +9,7 @@ import { getSecurityScoreSymbol, getSecurityScoreTextClass, getSecurityWarningPresentation, + shouldShowGlobalSecurityWarning, } from '@/utils/securityScorePresentation'; describe('securityScorePresentation', () => { @@ -109,6 +110,44 @@ describe('securityScorePresentation', () => { ).toBe('Authentication is enabled, but this Pulse instance is still missing HTTPS and an API token.'); }); + it('keeps global warnings off for private authenticated setup debt', () => { + expect( + shouldShowGlobalSecurityWarning({ + hasAuthentication: true, + exportProtected: true, + hasHTTPS: false, + publicAccess: false, + }), + ).toBe(false); + }); + + it('keeps global warnings on for active exposure states', () => { + expect( + shouldShowGlobalSecurityWarning({ + hasAuthentication: false, + exportProtected: true, + hasHTTPS: true, + publicAccess: false, + }), + ).toBe(true); + expect( + shouldShowGlobalSecurityWarning({ + hasAuthentication: true, + exportProtected: false, + hasHTTPS: true, + publicAccess: false, + }), + ).toBe(true); + expect( + shouldShowGlobalSecurityWarning({ + hasAuthentication: true, + exportProtected: true, + hasHTTPS: false, + publicAccess: true, + }), + ).toBe(true); + }); + it('returns canonical yes/no feature-state presentation', () => { expect(getSecurityFeatureStatePresentation(true)).toEqual({ label: 'Yes', diff --git a/frontend-modern/src/utils/securityScorePresentation.ts b/frontend-modern/src/utils/securityScorePresentation.ts index a0d07071b..e4cab3b0e 100644 --- a/frontend-modern/src/utils/securityScorePresentation.ts +++ b/frontend-modern/src/utils/securityScorePresentation.ts @@ -26,6 +26,13 @@ export interface SecurityWarningPresentation { messageClass: string; } +export interface SecurityRuntimeWarningVisibilityOptions { + hasAuthentication: boolean; + exportProtected: boolean; + hasHTTPS?: boolean; + publicAccess?: boolean; +} + export interface SecurityFeatureStatePresentation { label: 'Yes' | 'No'; className: string; @@ -174,6 +181,20 @@ export function getSecurityWarningPresentation(options: { }; } +export function shouldShowGlobalSecurityWarning( + options: SecurityRuntimeWarningVisibilityOptions, +): boolean { + if (!options.hasAuthentication) { + return true; + } + + if (!options.exportProtected) { + return true; + } + + return Boolean(options.publicAccess && !options.hasHTTPS); +} + export function getSecurityScoreTextClass(score: number): string { return getSecurityScorePresentation(score).tone.icon; }