diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a23fd895..420a19b4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; } diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index ac5468aa..055777fa 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -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()); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index c13ddad8..03815ea2 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -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, @@ -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>(() => new Set()); + useEffect(() => setExcludedControls(new Set()), [uuid]); if (isLoading) { return ( @@ -2139,7 +2144,20 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) { return (
- {scoreData && } + {scoreData && ( + + setExcludedControls((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + /> + )} {data.map((item) => ( ))} @@ -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; + 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 = { 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 (
@@ -2171,7 +2208,7 @@ function PostureScorePanel({ score }: { score: PostureScore }) { @@ -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}
{/* Summary */} @@ -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} + {excludedControls.size > 0 && ( + + {included.size}/{controls.length} checks counted + + )}
- {score.pass_count} pass - {score.warn_count} warn - {score.fail_count} fail + {displayed.pass_count} pass + {displayed.warn_count} warn + {displayed.fail_count} fail
+ {allExcluded && ( +

+ Every check is unchecked — check at least one to see a score. +

+ )} {/* Control results */}
{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 ( -
+ ); })}
diff --git a/frontend/src/features/nodes/postureScore.test.ts b/frontend/src/features/nodes/postureScore.test.ts new file mode 100644 index 00000000..ac668bc0 --- /dev/null +++ b/frontend/src/features/nodes/postureScore.test.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend/src/features/nodes/postureScore.ts b/frontend/src/features/nodes/postureScore.ts new file mode 100644 index 00000000..c034b671 --- /dev/null +++ b/frontend/src/features/nodes/postureScore.ts @@ -0,0 +1,81 @@ +import type { ControlResult, PostureScore } from '$/api/types'; + +// --------------------------------------------------------------------------- +// Client-side recompute of the aggregate posture score over a *subset* of +// the controls the server already evaluated — the posture tab's per-check +// "what if I ignore this" toggle. +// +// This mirrors only the aggregation math in pkg/posture/scoring.go +// (ScoreCalculator.Score's earned/possible normalization and riskLevel's +// escalation), not the individual rule Evaluate() functions — those already +// ran server-side and their outcome (status, score, max_score, severity) is +// on each ControlResult. Recomputing here never re-derives pass/warn/fail +// from raw posture rows, so there is no risk of drifting from the rules. +// +// Keep in sync with pkg/posture/scoring.go if either file changes: +// - RiskLevelFromScore's thresholds (70/40/15) +// - riskLevel's critical/high escalation on a failing control +// --------------------------------------------------------------------------- + +/** Mirrors posture.RiskLevelFromScore. */ +export function riskLevelFromScore(score: number): string { + if (score >= 70) return 'critical'; + if (score >= 40) return 'high'; + if (score >= 15) return 'medium'; + return 'low'; +} + +/** Mirrors posture.riskLevel's escalation by the worst failing control. */ +function escalatedRiskLevel(score: number, controls: ControlResult[]): string { + let level = riskLevelFromScore(score); + for (const c of controls) { + if (c.status !== 'fail') continue; + if (c.severity === 'critical') return 'critical'; + if (c.severity === 'high' && (level === 'low' || level === 'medium')) { + level = 'high'; + } + } + return level; +} + +/** Identifies a control within a PostureScore — matches the React list key. */ +export function controlKey(ctrl: ControlResult): string { + return ctrl.control_id + ctrl.category; +} + +/** + * Recomputes total_score, risk_level and the pass/warn/fail counts from + * only the controls whose key is in `included`. Passing the full set of + * keys reproduces the server's own PostureScore exactly — earned and + * max_score are read straight off each ControlResult, never re-derived. + */ +export function recomputePostureScore( + base: PostureScore, + included: ReadonlySet, +): PostureScore { + const controls = (base.controls ?? []).filter((c) => included.has(controlKey(c))); + + let earned = 0; + let possible = 0; + let passCount = 0; + let warnCount = 0; + let failCount = 0; + for (const c of controls) { + earned += c.score; + possible += c.max_score; + if (c.status === 'pass') passCount++; + else if (c.status === 'warn') warnCount++; + else if (c.status === 'fail') failCount++; + } + + const totalScore = possible > 0 ? Math.round((100 * earned) / possible) : 0; + + return { + ...base, + total_score: totalScore, + risk_level: escalatedRiskLevel(totalScore, controls), + pass_count: passCount, + warn_count: warnCount, + fail_count: failCount, + }; +} diff --git a/osctrl-api.yaml b/osctrl-api.yaml index 3d8c37c4..d5fa827f 100644 --- a/osctrl-api.yaml +++ b/osctrl-api.yaml @@ -8922,6 +8922,18 @@ components: type: string framework: $ref: "#/components/schemas/posture.Framework" + max_score: + description: >- + MaxScore is the risk points this control would contribute if it + + failed outright (its weight). A passing control still reports this so + + callers can renormalize the total after excluding controls — e.g. the + + SPA's "what if I ignore this check" recompute — without needing the + + evaluation rules themselves. + type: integer score: description: 0 = pass, otherwise earned risk points type: integer diff --git a/pkg/posture/scoring.go b/pkg/posture/scoring.go index 7664d377..e560c309 100644 --- a/pkg/posture/scoring.go +++ b/pkg/posture/scoring.go @@ -62,8 +62,14 @@ type ControlResult struct { Description string `json:"description"` Status string `json:"status"` // "pass", "warn", "fail" Severity Severity `json:"severity"` - Score int `json:"score"` // 0 = pass, otherwise earned risk points - Detail string `json:"detail"` // human-readable explanation + Score int `json:"score"` // 0 = pass, otherwise earned risk points + // MaxScore is the risk points this control would contribute if it + // failed outright (its weight). A passing control still reports this so + // callers can renormalize the total after excluding controls — e.g. the + // SPA's "what if I ignore this check" recompute — without needing the + // evaluation rules themselves. + MaxScore int `json:"max_score"` + Detail string `json:"detail"` // human-readable explanation } // PostureScore is the aggregate risk assessment for a node. @@ -256,6 +262,7 @@ func (sc *ScoreCalculator) Score(records []NodePosture) PostureScore { Description: rule.Description, Status: status, Severity: rule.Severity, + MaxScore: weight, Detail: detail, } diff --git a/pkg/posture/scoring_test.go b/pkg/posture/scoring_test.go index d2f048ba..c1e997d6 100644 --- a/pkg/posture/scoring_test.go +++ b/pkg/posture/scoring_test.go @@ -262,6 +262,17 @@ func TestScoreIsNormalizedToEvaluatedControls(t *testing.T) { if score.RiskLevel != "low" { t.Errorf("expected low risk, got %s", score.RiskLevel) } + // MaxScore lets a caller (the SPA's per-check what-if recompute) + // renormalize after excluding controls without knowing the rules — + // it must equal each control's weight regardless of pass/warn/fail, + // and summing it must reproduce the "possible" denominator above. + possible := 0 + for _, c := range score.Controls { + possible += c.MaxScore + } + if possible != 35 { + t.Errorf("expected MaxScore to sum to 35, got %d: %+v", possible, score.Controls) + } } func TestFailingHighControlRaisesLevelToHigh(t *testing.T) {