Assessor_2.0/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx

193 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}