Demote low-risk security warnings out of the global banner

This commit is contained in:
rcourtman
2026-03-30 22:43:23 +01:00
parent e685338fbc
commit 5de59c3faf
5 changed files with 107 additions and 15 deletions
@@ -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
@@ -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;
@@ -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<any>();
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<any>();
apiFetchJSONMock.mockReturnValue(pendingStatus.promise);
const { SecurityWarning } = await import('../SecurityWarning');
render(() => <SecurityWarning />);
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(
@@ -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',
@@ -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;
}