183 lines
11 KiB
TypeScript
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>;
|
|
}
|