193 lines
17 KiB
TypeScript
193 lines
17 KiB
TypeScript
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||
import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
|
||
import type { ImportedDataset } from '../../domain/dataset';
|
||
import type { Review } from '../../domain/review';
|
||
import {
|
||
defenderRiskCoverageDecisions,
|
||
defenderRiskTargetsForRecord,
|
||
type DefenderRiskTarget,
|
||
type ScopedRiskAcceptance,
|
||
type ScopedRiskAcceptanceWrite
|
||
} from '../../domain/scoped-risk-acceptance';
|
||
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||
import { RiskAcceptanceDialog } from '../finding-reader/RiskAcceptanceDialog';
|
||
import { defenderModelForDataset } from './defender-dataset-model';
|
||
|
||
type DefenderView = 'cves' | 'devices' | 'sites';
|
||
|
||
const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low'];
|
||
|
||
function label(value: string): string {
|
||
return value ? value.charAt(0).toLocaleUpperCase('en-US') + value.slice(1) : 'Unknown';
|
||
}
|
||
|
||
function Count({ value }: { value: number }) {
|
||
return <>{value.toLocaleString('en-US')}</>;
|
||
}
|
||
|
||
function Measure({ measure }: { measure: DefenderSeverityMeasure }) {
|
||
if (measure.state === 'unavailable') return <span className="defender-unavailable">Unavailable</span>;
|
||
if (measure.state === 'aggregate-only') return <span><Count value={measure.summary ?? 0} /> <small>aggregate-only</small></span>;
|
||
if (measure.state === 'mismatch') return <span><Count value={measure.detail} /> detailed <small>of {measure.summary?.toLocaleString('en-US') ?? 'unavailable'} summary</small></span>;
|
||
if (measure.state === 'detail-only') return <span><Count value={measure.detail} /> <small>detail only</small></span>;
|
||
return <Count value={measure.detail} />;
|
||
}
|
||
|
||
function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' }) {
|
||
return <span className={`defender-severity severity-${severity}`}><i aria-hidden="true" />{label(severity)}</span>;
|
||
}
|
||
|
||
function DeviceCves({ device, occurrences, id, coverage, coveringDecisions = [], onAcceptAll }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences']; id?: string; coverage?: string; coveringDecisions?: string[]; onAcceptAll?: () => void }) {
|
||
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||
return <div className="defender-drilldown" id={id}>
|
||
<h3>{device.displayName || 'Unnamed device'}</h3>
|
||
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p>
|
||
{coverage && <div className="defender-risk-coverage"><span>{coverage}</span>{onAcceptAll && <button className="btn primary" type="button" onClick={onAcceptAll}>Accept all current findings</button>}</div>}
|
||
{coveringDecisions.length > 0 && <details className="defender-risk-decisions"><summary>Covering decisions ({coveringDecisions.length.toLocaleString('en-US')})</summary><ul>{coveringDecisions.map(item => <li key={item}>{item}</li>)}</ul></details>}
|
||
<div className="defender-aggregate-summary" aria-label="Severity detail availability">
|
||
{severities.map(item => <span key={item}><strong>{label(item)}</strong><Measure measure={device.severity[item]} /></span>)}
|
||
</div>
|
||
{values.length ? <div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th></tr></thead><tbody>
|
||
{values.map(item => <tr key={item.id}><td>{item.cve}</td><td><SeverityMark severity={item.severity} /></td></tr>)}
|
||
</tbody></table></div> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>}
|
||
</div>;
|
||
}
|
||
|
||
export function DefenderVulnerabilityPanel({ dataset, navigation, reviews = {}, scopedRiskAcceptances = [], activeScanRunIds = [], onScopedRiskAcceptance }: {
|
||
dataset: ImportedDataset;
|
||
navigation?: ReactNode;
|
||
reviews?: Record<string, Review>;
|
||
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||
activeScanRunIds?: string[];
|
||
onScopedRiskAcceptance?(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||
}) {
|
||
const model = useMemo(() => defenderModelForDataset(dataset), [dataset]);
|
||
const [view, setView] = useState<DefenderView>('cves');
|
||
const [selectedCve, setSelectedCve] = useState('');
|
||
const [selectedDevice, setSelectedDevice] = useState('');
|
||
const [selectedSite, setSelectedSite] = useState('');
|
||
const [bulkDeviceKey, setBulkDeviceKey] = useState('');
|
||
const riskTargets = useMemo(() => dataset.sheets.flatMap(sheet => sheet.analysis.records.flatMap(record => defenderRiskTargetsForRecord(record, sheet.toolId))), [dataset]);
|
||
const targetsByDevice = useMemo(() => {
|
||
const result = new Map<string, DefenderRiskTarget[]>();
|
||
for (const target of riskTargets) result.set(target.device.key, [...(result.get(target.device.key) ?? []), target]);
|
||
return result;
|
||
}, [riskTargets]);
|
||
const uniqueTargetsForDevice = (deviceKey: string) => {
|
||
const unique = new Map<string, DefenderRiskTarget>();
|
||
for (const target of targetsByDevice.get(deviceKey) ?? []) if (!unique.has(target.definitionId)) unique.set(target.definitionId, target);
|
||
return [...unique.values()];
|
||
};
|
||
const bulkTargets = targetsByDevice.get(bulkDeviceKey) ?? [];
|
||
const bulkDefinitions = [...new Set(bulkTargets.map(target => target.definitionId))].sort();
|
||
const existingBulkAcceptance = scopedRiskAcceptances
|
||
.filter(item => item.target.kind === 'device-snapshot' && item.target.device.key === bulkDeviceKey
|
||
&& item.target.members.length === bulkDefinitions.length
|
||
&& item.target.members.every(member => bulkDefinitions.includes(member.findingDefinitionId)))
|
||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
|
||
const affectedDevices = model.devices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length;
|
||
const occurrenceSeverity = Object.fromEntries(severities.map(item => [item, model.occurrences.filter(occurrence => occurrence.severity === item).length])) as Record<DefenderSeverity, number>;
|
||
|
||
return <div className="dashboard-page client-command-center defender-page">
|
||
{navigation}
|
||
<header className="client-overview-header defender-header">
|
||
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
|
||
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
|
||
<div className="client-overview-source defender-source"><span>Source:</span><strong>Microsoft Defender for IoT</strong></div>
|
||
</header>
|
||
<section className="defender-metrics" aria-label="Vulnerability metrics">
|
||
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article>
|
||
<article><span>Unique CVEs</span><strong><Count value={model.cves.length} /></strong></article>
|
||
<article><span>Affected devices</span><strong><Count value={affectedDevices} /></strong></article>
|
||
<article><span>Device × CVE occurrences</span><strong><Count value={model.occurrences.length} /></strong></article>
|
||
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
|
||
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
|
||
</section>
|
||
<p className="defender-context"><span>{(model.summaryRowCount + model.inventoryRowCount).toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span></p>
|
||
<nav className="defender-tabs" aria-label="Defender vulnerability views">
|
||
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
|
||
</nav>
|
||
|
||
{view === 'cves' && <section className="defender-view" aria-labelledby="defender-cves-heading">
|
||
<header><h2 id="defender-cves-heading">CVE view</h2><span>{model.cves.length.toLocaleString('en-US')} unique definitions</span></header>
|
||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Affected devices</th><th>Sites</th><th>Sensors</th></tr></thead><tbody>
|
||
{model.cves.map((cve, index) => {
|
||
const expanded = selectedCve === cve.id;
|
||
const detailId = `defender-cve-detail-${index}`;
|
||
const sourceObservations = expanded
|
||
? model.occurrences.filter(item => item.cve === cve.id).flatMap(item => item.observations)
|
||
: [];
|
||
return <Fragment key={cve.id}>
|
||
<tr className={expanded ? 'defender-selected-row' : undefined}><td><button className="defender-row-toggle" type="button" aria-expanded={expanded} aria-controls={detailId} onClick={() => setSelectedCve(current => current === cve.id ? '' : cve.id)}><TriangleIcon direction={expanded ? 'down' : 'right'} />{cve.id}</button>{(cve.conflictingDescription || cve.conflictingSeverity) && <small className="defender-conflict">Review conflict</small>}</td><td><SeverityMark severity={cve.severity} /></td><td><Count value={cve.deviceIds.length} /></td><td>{cve.sites.join(', ') || 'Site unavailable'}</td><td>{cve.sensors.join(', ') || 'Sensor unavailable'}</td></tr>
|
||
{expanded && <tr className="defender-detail-row"><td colSpan={5}><div className="defender-drilldown" id={detailId}><h3>{cve.id}</h3><p>{cve.description || 'Description unavailable'}</p>
|
||
{(cve.conflictingDescription || cve.conflictingSeverity) && <p className="defender-warning">Conflicting source {cve.conflictingDescription && cve.conflictingSeverity ? 'descriptions and severity values' : cve.conflictingDescription ? 'descriptions' : 'severity values'} require review.</p>}
|
||
<h4>Affected devices and source observations</h4>
|
||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Device</th><th>IP</th><th>Site</th><th>Sensor</th></tr></thead><tbody>{sourceObservations.map(observation => <tr key={observation.id}><td>{observation.name || 'Unavailable'}</td><td>{observation.ip || 'Unavailable'}</td><td>{observation.site || 'Unavailable'}</td><td>{observation.sensor || 'Unavailable'}</td></tr>)}</tbody></table></div>
|
||
</div></td></tr>}
|
||
</Fragment>;
|
||
})}
|
||
</tbody></table></div>
|
||
</section>}
|
||
|
||
{view === 'devices' && <section className="defender-view" aria-labelledby="defender-devices-heading">
|
||
<header><h2 id="defender-devices-heading">Device view</h2><span>Detailed and aggregate-only counts stay separate</span></header>
|
||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Device</th><th>IP</th><th>Site</th><th>Sensors</th>{severities.map(item => <th key={item}>{label(item)}</th>)}</tr></thead><tbody>
|
||
{model.devices.map((device, index) => {
|
||
const expanded = selectedDevice === device.id;
|
||
const detailId = `defender-device-detail-${index}`;
|
||
return <Fragment key={device.id}>
|
||
<tr className={expanded ? 'defender-selected-row' : undefined}><td><button className="defender-row-toggle" type="button" aria-expanded={expanded} aria-controls={detailId} onClick={() => setSelectedDevice(current => current === device.id ? '' : device.id)}><TriangleIcon direction={expanded ? 'down' : 'right'} />{device.displayName || 'Unnamed device'}</button></td><td>{device.ip || 'Unavailable'}</td><td>{device.site || 'Unavailable'}</td><td>{device.sensors.join(', ') || 'Unavailable'}</td>{severities.map(item => <td key={item}><Measure measure={device.severity[item]} /></td>)}</tr>
|
||
{expanded && <tr className="defender-detail-row"><td colSpan={4 + severities.length}><DeviceCves device={device} occurrences={model.occurrences} id={detailId}
|
||
coverage={(() => {
|
||
const targets = uniqueTargetsForDevice(device.id);
|
||
const covered = targets.filter(target => defenderRiskCoverageDecisions(target, reviews[target.recordId]?.riskAcceptance, scopedRiskAcceptances).length > 0).length;
|
||
return targets.length ? `${covered.toLocaleString('en-US')} of ${targets.length.toLocaleString('en-US')} current findings covered by valid acceptance` : 'No actionable detailed CVE findings';
|
||
})()}
|
||
coveringDecisions={(() => {
|
||
const targets = uniqueTargetsForDevice(device.id);
|
||
const labels = new Set<string>();
|
||
for (const target of targets) {
|
||
for (const decision of defenderRiskCoverageDecisions(target, reviews[target.recordId]?.riskAcceptance, scopedRiskAcceptances)) {
|
||
if (decision.kind === 'whole-finding') labels.add(`Entire finding · ${target.cve} · ${decision.state}`);
|
||
else {
|
||
const acceptance = scopedRiskAcceptances.find(item => item.id === decision.id)!;
|
||
labels.add(`${decision.kind === 'device-snapshot' ? 'Device snapshot' : 'Device finding'} · ${decision.id} · ${acceptance.target.members.map(member => member.cve).join(', ')}`);
|
||
}
|
||
}
|
||
}
|
||
return [...labels];
|
||
})()}
|
||
onAcceptAll={(targetsByDevice.get(device.id) ?? []).some(target => target.eligible) && onScopedRiskAcceptance ? () => setBulkDeviceKey(device.id) : undefined} /></td></tr>}
|
||
</Fragment>;
|
||
})}
|
||
</tbody></table></div>
|
||
</section>}
|
||
|
||
{bulkDeviceKey && bulkTargets.length > 0 && <RiskAcceptanceDialog key={`${bulkDeviceKey}:${existingBulkAcceptance?.id ?? 'new'}`}
|
||
title={`All current findings on ${bulkTargets[0].device.displayName}`}
|
||
value={existingBulkAcceptance}
|
||
systemScope={`${bulkTargets[0].device.displayName}${bulkTargets[0].device.ip ? ` (${bulkTargets[0].device.ip})` : ''} · current selected Defender scan scope`}
|
||
memberLabels={[...new Set(bulkTargets.map(target => target.cve))].sort()}
|
||
onSave={onScopedRiskAcceptance ? command => onScopedRiskAcceptance(existingBulkAcceptance
|
||
? { acceptanceId: existingBulkAcceptance.id, command, expectedRevision: existingBulkAcceptance.revision }
|
||
: { target: { kind: 'device-snapshot', deviceKey: bulkDeviceKey, scanRunIds: activeScanRunIds.length ? activeScanRunIds : [...new Set(bulkTargets.map(target => target.scanRunId))] }, command, expectedRevision: 0 }) : undefined}
|
||
onClose={() => setBulkDeviceKey('')} />}
|
||
|
||
{view === 'sites' && <section className="defender-view" aria-labelledby="defender-sites-heading">
|
||
<header><h2 id="defender-sites-heading">Site dashboard</h2><span>Occurrence totals exclude aggregate-only counts</span></header>
|
||
<section className="defender-severity-strip" aria-label="Detailed occurrence severity distribution">{severities.map(item => <article key={item}><SeverityMark severity={item} /><strong><Count value={occurrenceSeverity[item]} /></strong></article>)}</section>
|
||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Site</th><th>Assets</th><th>Unique CVEs</th><th>Affected devices</th>{severities.map(item => <th key={item}>{label(item)} occurrences</th>)}<th>Aggregate-only</th><th>Raw observations</th><th>Detail completeness</th></tr></thead><tbody>
|
||
{model.sites.map((site, index) => {
|
||
const expanded = selectedSite === site.id;
|
||
const detailId = `defender-site-detail-${index}`;
|
||
return <Fragment key={site.id}>
|
||
<tr className={expanded ? 'defender-selected-row' : undefined}><td><button className="defender-row-toggle" type="button" aria-expanded={expanded} aria-controls={detailId} onClick={() => setSelectedSite(current => current === site.id ? '' : site.id)}><TriangleIcon direction={expanded ? 'down' : 'right'} />{site.name}</button></td><td><Count value={site.assetCount} /></td><td><Count value={site.uniqueCveCount} /></td><td><Count value={site.affectedDeviceCount} /></td>{severities.map(item => <td key={item}><Count value={site.occurrences[item]} /></td>)}<td><Count value={severities.reduce((sum, item) => sum + site.aggregateOnly[item], 0)} /></td><td><Count value={site.rawObservationCount} /></td><td>{site.detailCompleteness}</td></tr>
|
||
{expanded && <tr className="defender-detail-row"><td colSpan={7 + severities.length}><div className="defender-drilldown" id={detailId}><h3>{site.name}</h3><p>Select each device below to follow the site → device → CVE evidence path.</p>{model.devices.filter(device => site.deviceIds.includes(device.id)).map(device => <details key={device.id}><summary>{device.displayName || 'Unnamed device'} · {device.occurrenceIds.length.toLocaleString('en-US')} detailed CVE occurrence{device.occurrenceIds.length === 1 ? '' : 's'}</summary><DeviceCves device={device} occurrences={model.occurrences} /></details>)}</div></td></tr>}
|
||
</Fragment>;
|
||
})}
|
||
</tbody></table></div>
|
||
</section>}
|
||
</div>;
|
||
}
|