Assessor_2.0/src/features/dashboard/RiskRegister.tsx

183 lines
11 KiB
TypeScript

import { useState } from 'react';
import type { ImportedDataset } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
import { defenderRiskTargetsForRecord, scopedRiskAcceptanceState, type ScopedRiskAcceptance, type ScopedRiskAcceptanceRevision } from '../../domain/scoped-risk-acceptance';
import type { Review } from '../../domain/review';
import { TriangleIcon } from '../../components/TriangleIcon';
export interface RiskRegisterEntry {
id: string;
recordId?: string;
occurrenceId: string;
title: string;
source: string;
state: ReturnType<typeof riskAcceptanceState>;
latest: RiskAcceptanceRevision | ScopedRiskAcceptanceRevision;
revisions: Array<RiskAcceptanceRevision | ScopedRiskAcceptanceRevision>;
device?: string;
coveredCves?: string[];
}
function sourceName(record: FindingRecord): string {
return record.fields.find(field => /^(?:source|quelle):\s*scan$/i.test(field.label.trim()))?.value.trim()
|| record.sourceSheet?.trim()
|| 'Assessment';
}
/** One register row per canonical occurrence, even when several sheets project it. */
export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<string, Review>, today = new Date().toISOString().slice(0, 10), scoped: ScopedRiskAcceptance[] = []): RiskRegisterEntry[] {
const entries = new Map<string, RiskRegisterEntry>();
dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
const value = reviews[record.id]?.riskAcceptance;
const latest = value?.revisions.at(-1);
if (!latest) return;
const occurrenceId = record.findingOccurrenceId ?? latest.targetOccurrenceId ?? record.id;
if (entries.has(occurrenceId)) return;
entries.set(occurrenceId, {
id: occurrenceId,
recordId: record.id,
occurrenceId,
title: record.title || 'Untitled finding',
source: sourceName(record),
state: riskAcceptanceState(value, occurrenceId, today),
latest,
revisions: value!.revisions
});
}));
const targets = dataset.sheets.flatMap(sheet => sheet.analysis.records.flatMap(record => defenderRiskTargetsForRecord(record, sheet.toolId)));
for (const value of scoped) {
const latest = value.revisions.at(-1);
if (!latest) continue;
const currentTarget = targets.find(target => target.device.key === value.target.device.key
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId));
const coveredCves = value.target.members.map(member => member.cve).sort();
entries.set(value.id, {
id: value.id,
recordId: currentTarget?.recordId,
occurrenceId: currentTarget?.occurrenceId ?? value.target.members[0]?.capturedOccurrences[0]?.occurrenceId ?? value.id,
title: value.target.kind === 'device-snapshot'
? `All current findings on ${value.target.device.displayName}`
: `${coveredCves[0] ?? 'Finding'} on ${value.target.device.displayName}`,
source: 'Microsoft Defender for IoT',
state: scopedRiskAcceptanceState(value, today),
latest,
revisions: value.revisions,
device: value.target.device.displayName,
coveredCves
});
}
return [...entries.values()].sort((left, right) => right.latest.recordedAt.localeCompare(left.latest.recordedAt)
|| left.title.localeCompare(right.title));
}
function displayDate(value: string): string {
if (!value) return '—';
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00Z`) : new Date(value);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', timeZone: 'UTC'
}).format(date);
}
function stateTone(state: RiskRegisterEntry['state']): string {
if (state === 'Accepted' || state === 'Scheduled') return 'current';
if (state === 'Review due' || state === 'Draft') return 'attention';
if (state === 'Expired' || state === 'Review required') return 'critical';
return 'neutral';
}
function DetailField({ label, value }: { label: string; value: string }) {
return <div><dt>{label}</dt><dd>{value.trim() || '—'}</dd></div>;
}
function DetailGroup({ title, details, fields }: {
title: string;
details: RiskAcceptanceDetails;
fields: Array<[keyof RiskAcceptanceDetails, string]>;
}) {
return <details className="risk-register-detail-group">
<summary><TriangleIcon direction="right" /><h3>{title}</h3></summary>
<dl>{fields.map(([key, label]) => <DetailField key={key} label={label} value={details[key]} />)}</dl>
</details>;
}
export function RiskRegister({ dataset, reviews, scopedRiskAcceptances = [], onOpenFinding }: {
dataset: ImportedDataset;
reviews: Record<string, Review>;
scopedRiskAcceptances?: ScopedRiskAcceptance[];
onOpenFinding?(recordId: string): void;
}) {
const entries = riskRegisterEntries(dataset, reviews, undefined, scopedRiskAcceptances);
const [requestedId, setRequestedId] = useState('');
const selected = entries.find(entry => entry.id === requestedId) ?? entries[0];
const currentCount = entries.filter(entry => entry.state === 'Accepted' || entry.state === 'Review due').length;
return <section className="client-risk-register" aria-labelledby="client-risk-register-heading">
<div className="client-risk-register-layout">
<aside className="client-risk-register-navigation">
<header>
<div><h2 id="client-risk-register-heading">Accepted Risks</h2><span>Risk register</span></div>
<strong>{entries.length.toLocaleString('en-US')} recorded · {currentCount.toLocaleString('en-US')} current</strong>
</header>
<nav className="client-risk-register-list" aria-label="Accepted risks">
{entries.map((entry, index) => <button key={entry.id} type="button" className={`is-${stateTone(entry.state)}`} aria-pressed={entry.id === selected?.id} onClick={() => setRequestedId(entry.id)} onKeyDown={event => {
const targetIndex = event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1
: event.key === 'ArrowUp' ? Math.max(0, index - 1)
: event.key === 'ArrowDown' ? Math.min(entries.length - 1, index + 1) : index;
if (targetIndex === index || !['Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
setRequestedId(entries[targetIndex].id);
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button')[targetIndex]?.focus();
}}>
<span><strong>{entry.title}</strong><small>{entry.source}</small></span>
<em>{entry.state}</em>
<time dateTime={entry.latest.details.reviewOn || entry.latest.recordedAt}>Review {displayDate(entry.latest.details.reviewOn)}</time>
</button>)}
</nav>
</aside>
<main className="client-risk-register-information">
{!selected ? <p className="client-risk-register-empty">No risk acceptance has been recorded for this assessment.</p> : <article key={selected.id} className="client-risk-register-detail" aria-live="polite">
<header>
<div><strong>Risk Information</strong><span>{selected.source}</span></div>
<div className="risk-register-actions">{onOpenFinding && selected.recordId && <button className="btn primary" type="button" onClick={() => onOpenFinding(selected.recordId!)}>Open finding</button>}<strong className={`is-${stateTone(selected.state)}`}>{selected.state}</strong></div>
</header>
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2></section>
{selected.coveredCves && <section className="risk-register-scoped-target"><span>Immutable system scope</span><p>{selected.device}</p><p>{selected.coveredCves.length.toLocaleString('en-US')} covered CVE{selected.coveredCves.length === 1 ? '' : 's'}: {selected.coveredCves.join(', ')}</p></section>}
<dl className="risk-register-summary">
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
<DetailField label="Valid from" value={displayDate(selected.latest.details.validFrom)} />
<DetailField label="Expires" value={displayDate(selected.latest.details.expiresOn)} />
<DetailField label="Next review" value={displayDate(selected.latest.details.reviewOn)} />
<DetailField label="Tracking reference" value={selected.latest.details.trackingReference} />
</dl>
<div className="risk-register-detail-groups">
<DetailGroup title="Risk context" details={selected.latest.details} fields={[
['scope', 'Scope'], ['scenario', 'Risk scenario'], ['businessReason', 'Business rationale'],
['alternatives', 'Alternatives considered'], ['likelihood', 'Likelihood'], ['impact', 'Business impact'],
['residualAssessment', 'Residual risk assessment']
]} />
<DetailGroup title="Controls and monitoring" details={selected.latest.details} fields={[
['compensatingControls', 'Compensating controls'], ['controlOwner', 'Control owner'],
['controlEvidence', 'Control evidence'], ['monitoring', 'Monitoring'],
['reassessmentTriggers', 'Reassessment triggers'], ['exitPlan', 'Exit / remediation plan']
]} />
<DetailGroup title="Accountability and approval" details={selected.latest.details} fields={[
['riskOwner', 'Risk owner'], ['riskOwnerRole', 'Risk owner role / team'], ['riskOwnerContact', 'Risk owner contact'],
['approver', 'Approving authority'], ['approverRole', 'Approver role / authority'],
['approverContact', 'Approver contact'], ['approvalReference', 'Approval evidence'], ['approvedOn', 'Approved on']
]} />
<DetailGroup title="Governance and evidence" details={selected.latest.details} fields={[
['obligationsReview', 'Policy / regulatory review'], ['evidenceReferences', 'Additional evidence']
]} />
</div>
<details className="risk-register-history"><summary><TriangleIcon direction="right" /><h3>Decision history</h3><span>{selected.revisions.length} revisions</span></summary><ol>{[...selected.revisions].reverse().map(revision => <li key={revision.revision}>
<span><strong>{revision.action === 'accept' ? 'Accepted' : revision.action === 'revoke' ? 'Revoked' : 'Draft saved'}</strong><time dateTime={revision.recordedAt}>{displayDate(revision.recordedAt)}</time></span>
<p>{revision.changeReason}</p><small>Recorded by {revision.recordedBy} · Revision {revision.revision}</small>
</li>)}</ol></details>
</article>}
</main>
</div>
</section>;
}