Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ export interface ControlResult {
status: string;
severity: string;
score: number;
/**
* Risk points this control would contribute if it failed outright (its
* weight). Present regardless of status so a passing control can still
* be renormalized against — see `$/features/nodes/postureScore.ts`.
*/
max_score: number;
detail: string;
}

Expand Down
92 changes: 92 additions & 0 deletions frontend/src/features/nodes/NodeDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,98 @@ describe('NodeDetailPage', () => {
expect(screen.getByRole('button', { name: /firewall/i })).toBeInTheDocument();
});

it('recomputes the risk score live when a control is unchecked', async () => {
const user = userEvent.setup();
mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false });
mockGetNodePosture.mockResolvedValue([
{
id: 11,
created_at: '2026-07-16T09:00:00Z',
updated_at: '2026-07-16T09:05:00Z',
node_uuid: 'abc12345-0000-0000-0000-000000000001',
environment: 'test-env',
category: 'disk_encryption',
query_name: 'osctrl:posture:disk_encryption',
row_count: 1,
summary: JSON.stringify([{ encrypted: '0' }]),
first_seen: '2026-07-16T09:00:00Z',
last_seen: '2026-07-16T09:05:00Z',
},
]);
// earned = 30 (fail) + 0 (pass) = 30, possible = 30 + 5 = 35 -> 86.
// total_score/risk_level here match what the server's own aggregation
// would produce for these two controls, since the SPA always recomputes
// from each control's score/max_score rather than trusting this field
// verbatim — see recomputePostureScore.
mockGetNodePostureScore.mockResolvedValue({
node_uuid: 'abc12345-0000-0000-0000-000000000001',
timestamp: '2026-07-16T09:05:00Z',
total_score: 86,
risk_level: 'critical',
controls: [
{
category: 'disk_encryption',
control_id: 'A.8.24',
framework: 'ISO27001',
title: 'Disk encryption at rest',
description: '',
status: 'fail',
severity: 'critical',
score: 30,
max_score: 30,
detail: 'Disk is not encrypted',
},
{
category: 'patches',
control_id: 'A.8.9',
framework: 'ISO27001',
title: 'Patch management',
description: '',
status: 'pass',
severity: 'low',
score: 0,
max_score: 5,
detail: 'Up to date',
},
],
pass_count: 1,
warn_count: 0,
fail_count: 1,
});

renderWithProviders(makeTestRouter());

await waitFor(() => {
expect(screen.getByRole('heading', { name: 'web-server-01' })).toBeInTheDocument();
});
await user.click(screen.getByRole('tab', { name: 'Posture' }));

// Starts identical to the server score: both checks counted.
await waitFor(() => {
expect(screen.getByText('86')).toBeInTheDocument();
});
expect(screen.getByText('critical', { selector: 'span' })).toBeInTheDocument();

// Uncheck the failing critical control — only the passing low-severity
// one remains, so the score drops to 0 and the level to low. No new
// network request: this is a pure client-side recompute.
await user.click(screen.getByRole('checkbox', { name: /disk encryption at rest/i }));

await waitFor(() => {
expect(screen.getByText('0')).toBeInTheDocument();
});
expect(screen.getByText('low', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('1/2 checks counted')).toBeInTheDocument();
expect(mockGetNodePostureScore).toHaveBeenCalledTimes(1);

// Re-checking it restores the original score.
await user.click(screen.getByRole('checkbox', { name: /disk encryption at rest/i }));
await waitFor(() => {
expect(screen.getByText('86')).toBeInTheDocument();
});
expect(screen.queryByText('1/2 checks counted')).not.toBeInTheDocument();
});

it('hides posture tab while the posture feature is disabled', async () => {
renderWithProviders(makeTestRouter());

Expand Down
88 changes: 75 additions & 13 deletions frontend/src/features/nodes/NodeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { SearchInput } from '$/components/data/SearchInput';
import { ModalShell } from '$/components/feedback/ModalShell';
import { HealthBadge, TagChips } from './nodeSignals';
import { NodeFileExplorerTab } from './NodeFileExplorerTab';
import { recomputePostureScore, controlKey } from './postureScore';

// NodeHeatmapBucket is the merged per-node activity grid the heatmap renders.
// status/result/query come from the DB-backed logging buckets (full history,
Expand Down Expand Up @@ -2098,6 +2099,10 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) {
staleTime: 30_000,
retry: 1,
});
// Controls the operator has unchecked out of the risk calculation. Empty
// by default so the displayed score starts identical to the server's.
const [excludedControls, setExcludedControls] = useState<Set<string>>(() => new Set());
useEffect(() => setExcludedControls(new Set()), [uuid]);

if (isLoading) {
return (
Expand Down Expand Up @@ -2139,7 +2144,20 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) {

return (
<div className="overflow-auto p-4 space-y-3">
{scoreData && <PostureScorePanel score={scoreData} />}
{scoreData && (
<PostureScorePanel
score={scoreData}
excludedControls={excludedControls}
onToggleControl={(key) =>
setExcludedControls((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
/>
)}
{data.map((item) => (
<PostureCard key={item.category} item={item} />
))}
Expand All @@ -2150,15 +2168,34 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) {
// ---------------------------------------------------------------------------
// PostureScorePanel — risk score gauge + control summary
// ---------------------------------------------------------------------------
function PostureScorePanel({ score }: { score: PostureScore }) {
const controls = score.controls ?? [];
function PostureScorePanel({
score,
excludedControls,
onToggleControl,
}: {
score: PostureScore;
excludedControls: Set<string>;
onToggleControl: (key: string) => void;
}) {
const controls = useMemo(() => score.controls ?? [], [score.controls]);
// The gauge, badge and counts always reflect only the checked controls —
// recomputed client-side from the server's own per-control score/max_score,
// never re-derived from raw posture rows. With nothing unchecked this is
// byte-for-byte the server's PostureScore.
const included = useMemo(
() => new Set(controls.map(controlKey).filter((key) => !excludedControls.has(key))),
[controls, excludedControls],
);
const displayed = useMemo(() => recomputePostureScore(score, included), [score, included]);

const riskColors: Record<string, string> = {
low: 'var(--success)',
medium: 'var(--warning)',
high: 'var(--danger)',
critical: 'var(--danger)',
};
const riskColor = riskColors[score.risk_level] ?? 'var(--text-3)';
const riskColor = riskColors[displayed.risk_level] ?? 'var(--text-3)';
const allExcluded = controls.length > 0 && included.size === 0;

return (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--bg-1)] overflow-hidden">
Expand All @@ -2171,7 +2208,7 @@ function PostureScorePanel({ score }: { score: PostureScore }) {
<circle
cx="32" cy="32" r="28" fill="none"
stroke={riskColor} strokeWidth="6"
strokeDasharray={`${(score.total_score / 100) * 176} 176`}
strokeDasharray={`${(displayed.total_score / 100) * 176} 176`}
strokeLinecap="round"
transform="rotate(-90 32 32)"
/>
Expand All @@ -2180,7 +2217,7 @@ function PostureScorePanel({ score }: { score: PostureScore }) {
className="absolute inset-0 flex items-center justify-center text-lg font-bold font-mono-tabular"
style={{ color: riskColor }}
>
{score.total_score}
{displayed.total_score}
</span>
</div>
{/* Summary */}
Expand All @@ -2193,24 +2230,49 @@ function PostureScorePanel({ score }: { score: PostureScore }) {
className="px-1.5 py-0.5 rounded text-[10px] font-mono-tabular font-semibold uppercase tracking-[0.08em] border"
style={{ color: riskColor, borderColor: riskColor, background: `color-mix(in oklab, ${riskColor} 10%, transparent)` }}
>
{score.risk_level}
{displayed.risk_level}
</span>
{excludedControls.size > 0 && (
<span className="text-[10px] text-[color:var(--text-3)]" title="Unchecked controls below are excluded from this score">
{included.size}/{controls.length} checks counted
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] font-mono-tabular">
<span className="text-[color:var(--success)]">{score.pass_count} pass</span>
<span className="text-[color:var(--warning)]">{score.warn_count} warn</span>
<span className="text-[color:var(--danger)]">{score.fail_count} fail</span>
<span className="text-[color:var(--success)]">{displayed.pass_count} pass</span>
<span className="text-[color:var(--warning)]">{displayed.warn_count} warn</span>
<span className="text-[color:var(--danger)]">{displayed.fail_count} fail</span>
</div>
{allExcluded && (
<p className="mt-1 text-[10px] text-[color:var(--text-3)]">
Every check is unchecked — check at least one to see a score.
</p>
)}
</div>
</div>
{/* Control results */}
<div className="divide-y divide-[color:var(--border)]">
{controls.map((ctrl) => {
const key = controlKey(ctrl);
const checked = !excludedControls.has(key);
const statusColor = ctrl.status === 'pass' ? 'var(--success)' : ctrl.status === 'warn' ? 'var(--warning)' : 'var(--danger)';
return (
<div key={ctrl.control_id + ctrl.category} className="px-4 py-2 flex items-start gap-3">
<label
key={key}
className={cn(
'px-4 py-2 flex items-start gap-3 cursor-pointer hover:bg-[color:var(--bg-2)] transition-colors',
!checked && 'opacity-50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleControl(key)}
className="mt-0.5 flex-shrink-0 accent-[color:var(--signal)]"
aria-label={`Include ${ctrl.title} in risk score`}
/>
<span
className="flex-shrink-0 mt-0.5 inline-block w-2 h-2 rounded-full"
className="flex-shrink-0 mt-1.5 inline-block w-2 h-2 rounded-full"
style={{ background: statusColor }}
/>
<div className="flex-1 min-w-0">
Expand All @@ -2226,7 +2288,7 @@ function PostureScorePanel({ score }: { score: PostureScore }) {
+{ctrl.score}
</span>
)}
</div>
</label>
);
})}
</div>
Expand Down
119 changes: 119 additions & 0 deletions frontend/src/features/nodes/postureScore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { recomputePostureScore, riskLevelFromScore, controlKey } from './postureScore';
import type { ControlResult, PostureScore } from '$/api/types';

function control(overrides: Partial<ControlResult>): ControlResult {
return {
category: 'disk_encryption',
control_id: 'A.8.24',
framework: 'ISO27001',
title: 'Disk encryption',
description: '',
status: 'pass',
severity: 'critical',
score: 0,
max_score: 30,
detail: '',
...overrides,
};
}

function baseScore(controls: ControlResult[]): PostureScore {
return {
node_uuid: 'node-1',
timestamp: '2026-08-21T00:00:00Z',
total_score: 0,
risk_level: 'low',
controls,
pass_count: 0,
warn_count: 0,
fail_count: 0,
};
}

describe('riskLevelFromScore', () => {
it('mirrors the Go thresholds', () => {
expect(riskLevelFromScore(0)).toBe('low');
expect(riskLevelFromScore(14)).toBe('low');
expect(riskLevelFromScore(15)).toBe('medium');
expect(riskLevelFromScore(39)).toBe('medium');
expect(riskLevelFromScore(40)).toBe('high');
expect(riskLevelFromScore(69)).toBe('high');
expect(riskLevelFromScore(70)).toBe('critical');
});
});

describe('recomputePostureScore', () => {
it('reproduces the server score when every control is included', () => {
const controls = [
control({ control_id: 'A.8.24', status: 'fail', severity: 'critical', score: 30, max_score: 30 }),
control({ control_id: 'CC6.1', category: 'users', status: 'warn', severity: 'high', score: 5, max_score: 20 }),
control({ control_id: 'A.8.9', category: 'patches', status: 'pass', severity: 'low', score: 0, max_score: 5 }),
];
const score = baseScore(controls);
const included = new Set(controls.map(controlKey));

const result = recomputePostureScore(score, included);

// earned = 30 + 5 + 0 = 35, possible = 30 + 20 + 5 = 55 -> round(100*35/55) = 64
expect(result.total_score).toBe(64);
expect(result.pass_count).toBe(1);
expect(result.warn_count).toBe(1);
expect(result.fail_count).toBe(1);
// A failing critical control always escalates to critical, regardless
// of the normalized score.
expect(result.risk_level).toBe('critical');
});

it('drops an unchecked control from both the numerator and denominator', () => {
const controls = [
control({ control_id: 'A.8.24', status: 'fail', severity: 'critical', score: 30, max_score: 30 }),
control({ control_id: 'A.8.9', category: 'patches', status: 'pass', severity: 'low', score: 0, max_score: 5 }),
];
const score = baseScore(controls);
// Uncheck the failing critical control — only the passing low-severity
// one remains.
const included = new Set([controlKey(controls[1])]);

const result = recomputePostureScore(score, included);

expect(result.total_score).toBe(0);
expect(result.risk_level).toBe('low');
expect(result.fail_count).toBe(0);
expect(result.pass_count).toBe(1);
});

it('escalates to high only when the normalized score was low or medium', () => {
const failingHigh = control({ control_id: 'CC6.1', category: 'users', status: 'fail', severity: 'high', score: 20, max_score: 20 });
// A single failing high control among nothing else normalizes to 100,
// which is already >= 70 -> critical is NOT forced (only failing
// critical severities force critical), but the threshold alone lands
// on critical here since 100 >= 70.
const soloResult = recomputePostureScore(baseScore([failingHigh]), new Set([controlKey(failingHigh)]));
expect(soloResult.risk_level).toBe('critical');

// Diluted by enough passing weight, the normalized score drops below
// the high threshold, and the failing-high escalation is what raises
// it back to "high" rather than leaving it at "medium".
const passing = control({ control_id: 'A.8.9', category: 'patches', status: 'pass', severity: 'low', score: 0, max_score: 200 });
const controls = [failingHigh, passing];
const dilutedResult = recomputePostureScore(baseScore(controls), new Set(controls.map(controlKey)));
expect(dilutedResult.total_score).toBeLessThan(40);
expect(dilutedResult.risk_level).toBe('high');
});

it('scores an empty selection as 0/low rather than dividing by zero', () => {
const controls = [control({ status: 'fail', score: 30, max_score: 30 })];
const result = recomputePostureScore(baseScore(controls), new Set());

expect(result.total_score).toBe(0);
expect(result.risk_level).toBe('low');
expect(result.pass_count).toBe(0);
expect(result.fail_count).toBe(0);
});

it('keys controls by control_id + category, matching the rendered list key', () => {
const ctrl = control({ control_id: 'A.8.9', category: 'patches' });
expect(controlKey(ctrl)).toBe('A.8.9patches');
});
});
Loading
Loading