mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Demote low-risk security warnings out of the global banner
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user