fix: Adjust NvdCveClient to correctly handle baseSeverity from CVSS metrics
This commit is contained in:
parent
08771d5cbb
commit
b438f25fd8
|
|
@ -11,6 +11,7 @@ vite.config.js
|
|||
vite.config.d.ts
|
||||
*.png
|
||||
artifacts/objects/
|
||||
Mock-Defender/
|
||||
|
||||
.env.postgres
|
||||
.assessor-data/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
Status: lebendes Projektdokument
|
||||
|
||||
Defender-Device-CVE-Kurzliste 2026-09-11 (Phase 7, Nutzerpraezisierung): Das Device-Detail und die dort wiederverwendete Device-Liste innerhalb eines Site-Details zeigen je zugeordnetem CVE nur noch CVE-Kennung und Severity. Description und Reporting sensors entfallen aus dieser Tabelle, weil die CVE-Beschreibung in der CVE-Ansicht und der Sensor bereits im Device-Kontext verfuegbar sind. Eine synthetische Interaktionsregression sichert den exakten Zwei-Spalten-Vertrag. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; Quelldaten bleiben im Modell erhalten, keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-CVE-Evidenzliste 2026-09-11 (Phase 7, Nutzerpraezisierung): Das geoeffnete CVE-Detail zeigt alle betroffenen Geraete und ihre Sensorbeobachtungen unmittelbar in einer gemeinsamen flachen Liste statt als einzeln aufklappbare Device-Gruppen. Die Liste enthaelt nur Device, IP, Site und Sensor; interne Source-row-Provenienz und die bereits durch das ausgewaehlte CVE feststehende Severity werden nicht redundant angezeigt. Die Detailprojektion wird nur fuer das aktuell geoeffnete CVE aufgebaut. Eine synthetische Regression mit zwei Geraeten fuer dasselbe CVE prueft fehlende Unter-Expander, beide sichtbaren Zeilen und den exakten Vier-Spalten-Vertrag. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; Quellprovenienz und Severity bleiben im Modell erhalten, keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-Inline-Detailflaeche 2026-09-11 (Phase 7, Nutzerkorrektur): Aufgeklappte CVE-, Device- und Site-Details verwenden wieder dieselbe themenabhaengige `--tile-surface` wie die zugehoerige Tabellenliste statt der deutlich dunkleren `--client-info-surface`. Gelbe Auswahlmarkierung, Detailgrenze, Inhalte und Interaktion bleiben unveraendert. Eine Stilregression sichert die gemeinsame Flaeche und schliesst den dunklen Client-Info-Token fuer `.defender-drilldown` aus. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-CVE-Observations nur im Detail 2026-09-11 (Phase 7, Nutzerpraezisierung): Die CVE-Uebersicht entfernt die fuer den aktuellen Ein-Sensor-je-Geraet-Datenstand redundante Observations-Spalte. Affected devices bleibt als eindeutige Geraetezahl sichtbar; die Anzahl der zugrunde liegenden Quellbeobachtungen bleibt je Geraet im aufgeklappten Evidenzdetail erhalten, damit kuenftige Mehrfachmeldungen weiterhin nachvollziehbar sind. Die synthetische Interaktionsregression prueft die nun fuenf Uebersichtsspalten und die unveraenderte Detaildarstellung. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-CVE-Beschreibung nur im Detail 2026-09-11 (Phase 7, Nutzerpraezisierung): Die CVE-Uebersicht entfernt die redundante Description-Spalte; die vollstaendige Beschreibung bleibt unveraendert direkt im aufgeklappten Inline-Detail sichtbar. Affected devices bezeichnet weiterhin die Anzahl unterschiedlicher betroffener Geraete, Observations die Anzahl der zugrunde liegenden Sensor-Quellzeilen und kann deshalb durch mehrere Sensoren oder wiederholte Meldungen groesser sein. Die synthetische Interaktionsregression prueft die sechs Uebersichtsspalten sowie die erhaltene Beschreibung im Detail. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-for-IoT-Inline-Drilldowns 2026-09-11 (Phase 7, Nutzerkorrektur): CVE-, Device- und Site-Details erscheinen jetzt unmittelbar unter der ausgewaehlten Tabellenzeile statt gesammelt am Ende der jeweiligen Liste. Die vorhandenen Bezeichnungsbuttons verwenden ein einheitliches Auf-/Zuklappdreieck, `aria-expanded` und stabile `aria-controls`-Ziele; ein erneuter Klick schliesst das Detail, eine andere Auswahl verschiebt das einzige offene Detail. Die ausgewaehlte Zeile nutzt die gemeinsame gelbe Markierung, waehrend der Tastaturfokus blau bleibt. Verschachtelte Evidenztabellen bleiben innerhalb ihrer Detailzeile horizontal scrollbar und werden weder abgeschnitten noch verkuerzt. Synthetische Interaktionstests pruefen Oeffnen, Verschieben, Schliessen, unmittelbare Zeilennachbarschaft und Zugaenglichkeitszustand in allen drei Ansichten. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
|
||||
|
||||
Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7, Nutzerpraezisierung): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit unveraenderten CVE-, Device- und Site-Ansichten. Die Registerkarte verwendet jetzt denselben themenabhaengigen Client-Info-Canvas, dieselbe Client-/Assessment-/Source-Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich. Verifikation: 1.239 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert.
|
||||
|
||||
Reimport-Schutz 2026-09-10 (Phase 6/8/9/13C mit Phase-7-Hinweis, ausdruecklicher Nutzerauftrag): Verarbeitungsrelevante Aenderungen an Formaterkennung, Parser/Extractor, Scannerprofil, stabiler Identitaet, Mapping, Normalisierung, Status-/Evidenzvertrag oder nativer Scoregewinnung muessen ueber append-only Processing-Manifeste versioniert und fuer bestehende Importe als Reimportbedarf sichtbar werden; reine Darstellungsaenderungen auf bereits gespeicherten Daten benoetigen keinen Reimport. Generische/tabellarische Importe ohne belastbares Profil erscheinen vorsorglich als gelber Pruefstatus. Direkter Reimport und die Uebernahme eines bereits importierten Ersatzlaufs uebertragen kundenbezogene Finding-Reviews atomar in lokaler und PostgreSQL-Persistenz. Die Zuordnung verwendet ausschliesslich die eindeutige stabile Identitaet aus Scannerwerkzeug, nativer Regel-ID und betroffenem Asset; bei fehlender oder mehrdeutiger Zuordnung wird nicht nach Titel oder Position geraten. Der vollstaendige Reviewvertrag wird kopiert und erhaelt dadurch Status, Prioritaet, Action-Plan-Zuordnung, Owner, Termin, Kommentar, Voraussetzungen, Zugriffe, naechsten Schritt, Akzeptanzevidenz, Marker/Accepted Risk sowie spaetere additive JSON-Felder. Bereits vorhandene Angaben am Ersatzlauf haben Vorrang. Nicht eindeutig uebertragbare Angaben bleiben am auditierbar erhaltenen Ursprungslauf gespeichert; Anzahl und dauerhafter `Review required`-Hinweis werden am Ersatzimport persistiert. Original-SHA, Vorgaengerbezug und Uebertragungsherkunft bleiben nachvollziehbar. Regeln und Szenarien stehen in `docs/REIMPORT_POLICY.md`. Verifikation: 1.205 Tests bestanden, acht optionale Tests uebersprungen; Local-only- und Designpruefung erfolgreich. Keine SQL-Migration, Realdateninspektion oder kanonische Datenmutation; Phasenstatus unveraendert.
|
||||
|
|
|
|||
|
|
@ -63,12 +63,14 @@ function metrics(value: unknown): CveMetric[] {
|
|||
const baseScore = number(data?.baseScore);
|
||||
const vector = string(data?.vectorString);
|
||||
if (!item || !data || baseScore === undefined || !vector) return [];
|
||||
// CVSS v3.x/v4.0 carry baseSeverity inside cvssData; CVSS v2 carries it on the metric entry itself.
|
||||
const baseSeverity = string(data.baseSeverity) || string(item.baseSeverity);
|
||||
return [{
|
||||
version,
|
||||
source: string(item.source) || 'unknown',
|
||||
...(string(item.type) ? { type: string(item.type) } : {}),
|
||||
baseScore,
|
||||
...(string(data.baseSeverity) ? { baseSeverity: string(data.baseSeverity) } : {}),
|
||||
...(baseSeverity ? { baseSeverity } : {}),
|
||||
vector,
|
||||
...(number(item.exploitabilityScore) === undefined ? {} : { exploitabilityScore: number(item.exploitabilityScore) }),
|
||||
...(number(item.impactScore) === undefined ? {} : { impactScore: number(item.impactScore) })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
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 { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||||
|
|
@ -28,17 +29,17 @@ function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' })
|
|||
return <span className={`defender-severity severity-${severity}`}><i aria-hidden="true" />{label(severity)}</span>;
|
||||
}
|
||||
|
||||
function DeviceCves({ device, occurrences }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences'] }) {
|
||||
function DeviceCves({ device, occurrences, id }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences']; id?: string }) {
|
||||
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||||
return <div className="defender-drilldown">
|
||||
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>
|
||||
<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 ? <table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Description</th><th>Reporting sensors</th></tr></thead><tbody>
|
||||
{values.map(item => <tr key={item.id}><td>{item.cve}</td><td><SeverityMark severity={item.severity} /></td><td>{item.description || 'Description unavailable'}</td><td>{item.sensors.join(', ') || 'Sensor unavailable'}</td></tr>)}
|
||||
</tbody></table> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>}
|
||||
{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>;
|
||||
}
|
||||
|
||||
|
|
@ -48,9 +49,6 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
|
|||
const [selectedCve, setSelectedCve] = useState('');
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
const currentCve = model.cves.find(item => item.id === selectedCve);
|
||||
const currentDevice = model.devices.find(item => item.id === selectedDevice);
|
||||
const currentSite = model.sites.find(item => item.id === selectedSite);
|
||||
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>;
|
||||
|
||||
|
|
@ -76,36 +74,52 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
|
|||
|
||||
{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>Description</th><th>Affected devices</th><th>Sites</th><th>Sensors</th><th>Observations</th></tr></thead><tbody>
|
||||
{model.cves.map(cve => <tr key={cve.id}><td><button type="button" aria-pressed={selectedCve === cve.id} onClick={() => setSelectedCve(cve.id)}>{cve.id}</button>{(cve.conflictingDescription || cve.conflictingSeverity) && <small className="defender-conflict">Review conflict</small>}</td><td><SeverityMark severity={cve.severity} /></td><td>{cve.description || 'Description unavailable'}</td><td><Count value={cve.deviceIds.length} /></td><td>{cve.sites.join(', ') || 'Site unavailable'}</td><td>{cve.sensors.join(', ') || 'Sensor unavailable'}</td><td><Count value={cve.observationCount} /></td></tr>)}
|
||||
</tbody></table></div>
|
||||
{currentCve && <div className="defender-drilldown"><h3>{currentCve.id}</h3><p>{currentCve.description || 'Description unavailable'}</p>
|
||||
{(currentCve.conflictingDescription || currentCve.conflictingSeverity) && <p className="defender-warning">Conflicting source {currentCve.conflictingDescription && currentCve.conflictingSeverity ? 'descriptions and severity values' : currentCve.conflictingDescription ? 'descriptions' : 'severity values'} require review.</p>}
|
||||
<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>
|
||||
{model.occurrences.filter(item => item.cve === currentCve.id).map(occurrence => {
|
||||
const device = model.devices.find(item => item.id === occurrence.deviceId);
|
||||
return <details key={occurrence.id}><summary>{device?.displayName || 'Unnamed device'} · {device?.site || 'Site unavailable'} · {occurrence.observations.length.toLocaleString('en-US')} observation{occurrence.observations.length === 1 ? '' : 's'}</summary>
|
||||
<table className="defender-table"><thead><tr><th>Source row</th><th>Name</th><th>IP</th><th>Site</th><th>Sensor</th><th>Severity</th></tr></thead><tbody>{occurrence.observations.map(observation => <tr key={observation.id}><td>{observation.rowIndex + 1}</td><td>{observation.name || 'Unavailable'}</td><td>{observation.ip || 'Unavailable'}</td><td>{observation.site || 'Unavailable'}</td><td>{observation.sensor || 'Unavailable'}</td><td>{observation.severitySource || 'Unavailable'}</td></tr>)}</tbody></table>
|
||||
</details>;
|
||||
<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>;
|
||||
})}
|
||||
</div>}
|
||||
</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 => <tr key={device.id}><td><button type="button" aria-pressed={selectedDevice === device.id} onClick={() => setSelectedDevice(device.id)}>{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>)}
|
||||
{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} /></td></tr>}
|
||||
</Fragment>;
|
||||
})}
|
||||
</tbody></table></div>
|
||||
{currentDevice && <DeviceCves device={currentDevice} occurrences={model.occurrences} />}
|
||||
</section>}
|
||||
|
||||
{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 => <tr key={site.id}><td><button type="button" aria-pressed={selectedSite === site.id} onClick={() => setSelectedSite(site.id)}>{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>)}
|
||||
{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>
|
||||
{currentSite && <div className="defender-drilldown"><h3>{currentSite.name}</h3><p>Select each device below to follow the site → device → CVE evidence path.</p>{model.devices.filter(device => currentSite.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>}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,10 +50,6 @@ function field(key: string, label: string, originalValue: JsonValue, decodedValu
|
|||
return { key, label, originalValue, decodedValue, source };
|
||||
}
|
||||
|
||||
function deviceLabel(device: DefenderDevice | undefined, fallback: string): string {
|
||||
return device?.displayName || device?.ip || fallback;
|
||||
}
|
||||
|
||||
function cell(value: string): string {
|
||||
// Zellen einer Markdown-Tabelle: Pipe und Zeilenumbruch wuerden die
|
||||
// Tabellenstruktur brechen, deshalb neutralisiert.
|
||||
|
|
@ -63,10 +59,15 @@ function cell(value: string): string {
|
|||
// Die betroffenen Devices je CVE als Markdown-Tabelle. Sie wird im
|
||||
// Ergebnisfenster als Tabelle ausgewertet (siehe RichTextValue) und ersetzt so
|
||||
// die frueher als Rohtext gezeigten Source Observations. Eine Zeile je Device
|
||||
// (Occurrence) mit den meldenden Sensoren und dem Standort.
|
||||
// (Occurrence) mit Name und IP-Adresse getrennt, den meldenden Sensoren und dem
|
||||
// Standort.
|
||||
function affectedDevicesTable(occurrences: DefenderOccurrence[], deviceById: Map<string, DefenderDevice>): string {
|
||||
const header = '| Affected Device | Scanner | Site |\n| --- | --- | --- |';
|
||||
const rows = occurrences.map(occurrence => `| ${cell(deviceLabel(deviceById.get(occurrence.deviceId), occurrence.deviceId))} | ${cell(occurrence.sensors.join(', '))} | ${cell(occurrence.sites.join(', '))} |`);
|
||||
const header = '| Name | IP Address | Scanner | Site |\n| --- | --- | --- | --- |';
|
||||
const rows = occurrences.map(occurrence => {
|
||||
const device = deviceById.get(occurrence.deviceId);
|
||||
const name = device?.displayName || occurrence.deviceId;
|
||||
return `| ${cell(name)} | ${cell(device?.ip ?? '')} | ${cell(occurrence.sensors.join(', '))} | ${cell(occurrence.sites.join(', '))} |`;
|
||||
});
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,19 @@
|
|||
.defender-table th { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); text-align: left; text-transform: uppercase; vertical-align: bottom; }
|
||||
.defender-table td { max-width: 440px; padding: var(--space-3); border-bottom: var(--border-hair) solid var(--line); line-height: 1.45; overflow-wrap: normal; word-break: normal; vertical-align: top; }
|
||||
.defender-table td > button { padding: 0; border: 0; color: var(--heading); background: transparent; font: inherit; font-weight: var(--fw-semibold); text-align: left; cursor: pointer; }
|
||||
.defender-table .defender-row-toggle { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||
.defender-table .defender-row-toggle .triangle-icon { flex: 0 0 auto; }
|
||||
.defender-table td > button:hover { background: var(--tile-surface-hover); }
|
||||
.defender-table td > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.defender-table td > button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: 2px; }
|
||||
.defender-selected-row > td { background: var(--tile-surface-hover); }
|
||||
.defender-selected-row > td:first-child { box-shadow: inset 4px 0 var(--marking-color); }
|
||||
.defender-detail-row > td { max-width: none; padding: 0 0 var(--space-3); border-bottom-color: var(--marking-color); }
|
||||
.defender-table small { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-severity { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
|
||||
.defender-conflict, .defender-warning { color: var(--warning); }
|
||||
.defender-unavailable, .defender-empty { color: var(--muted); }
|
||||
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--client-info-surface); box-shadow: none; }
|
||||
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); box-shadow: none; }
|
||||
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
|
||||
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
|
||||
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('Defender vulnerability dashboard projection', () => {
|
|||
expect(component).toContain('CVE view');
|
||||
expect(component).toContain('Device view');
|
||||
expect(component).toContain('Site dashboard');
|
||||
expect(component).toContain('Source row');
|
||||
expect(component).not.toContain('<th>Source row</th>');
|
||||
expect(component).toContain('aggregate-only');
|
||||
expect(component).toContain('className="dashboard-page client-command-center defender-page"');
|
||||
expect(component).toContain('className="client-overview-header defender-header"');
|
||||
|
|
@ -57,6 +57,8 @@ describe('Defender vulnerability dashboard projection', () => {
|
|||
expect(styles).toContain('padding: var(--panel-inset)');
|
||||
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
|
||||
expect(styles).toContain('background: var(--tile-surface)');
|
||||
expect(styles).toMatch(/\.defender-drilldown \{[^}]*background: var\(--tile-surface\)/);
|
||||
expect(styles).not.toMatch(/\.defender-drilldown \{[^}]*background: var\(--client-info-surface\)/);
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
|
||||
expect(styles).not.toContain('text-overflow: ellipsis');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||
import { rowsToCsv } from '../../src/exports/csv-export';
|
||||
import { DefenderVulnerabilityPanel } from '../../src/features/defender-for-iot/DefenderVulnerabilityPanel';
|
||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||
|
||||
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({
|
||||
WorkTimeslotControl: () => <select aria-label="Synthetic timeslot" />
|
||||
}));
|
||||
|
||||
function dataset(): ImportedDataset {
|
||||
const summary = [
|
||||
['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'],
|
||||
['device-a', '192.0.2.1', 'Site A', 'sensor-a', '1', '1', '0', '0', '0'],
|
||||
['device-b', '192.0.2.2', 'Site B', 'sensor-b', '2', '1', '1', '0', '0']
|
||||
];
|
||||
const observationA = { name: 'device-a', ip: '192.0.2.1', site: 'Site A', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description A', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 1 } };
|
||||
const observationB = { name: 'device-b', ip: '192.0.2.2', site: 'Site B', sensor: 'sensor-b', cve: 'CVE-2026-1002', severity: 'High', description: 'Synthetic description B', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 2 } };
|
||||
const observationC = { name: 'device-b', ip: '192.0.2.2', site: 'Site B', sensor: 'sensor-b', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description A', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 3 } };
|
||||
const detail = [
|
||||
['CVE', 'Device Identity', 'Name', 'IP', 'Site', 'Reporting Sensors', 'Severity', 'Beschreibung', 'Observation Count', 'Source Observations'],
|
||||
['CVE-2026-1001', 'ip:192.0.2.1', 'device-a', '192.0.2.1', 'Site A', 'sensor-a', 'Critical', 'Synthetic description A', '1', JSON.stringify([observationA])],
|
||||
['CVE-2026-1002', 'ip:192.0.2.2', 'device-b', '192.0.2.2', 'Site B', 'sensor-b', 'High', 'Synthetic description B', '1', JSON.stringify([observationB])],
|
||||
['CVE-2026-1001', 'ip:192.0.2.2', 'device-b', '192.0.2.2', 'Site B', 'sensor-b', 'Critical', 'Synthetic description A', '1', JSON.stringify([observationC])]
|
||||
];
|
||||
return {
|
||||
fileName: 'Synthetic project', fileSize: 1, lastModified: 0, format: 'Project · Microsoft Defender for IoT', rawText: '',
|
||||
profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', scanTool: 'Microsoft Defender for IoT',
|
||||
sheets: [
|
||||
makeSheet('Vulnerabilities', detail, rowsToCsv(detail), 'synthetic.xlsx', { sourceTableIndex: 1, sourceSheet: 'Vulnerabilities', toolId: 'defender-for-iot-vulnerabilities' }),
|
||||
makeSheet('Assets', summary, rowsToCsv(summary), 'synthetic.xlsx', { sourceTableIndex: 0, sourceSheet: 'Assets', toolId: 'defender-for-iot-vulnerabilities' })
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function toggleButtons(container: HTMLElement): HTMLButtonElement[] {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>('.defender-view .defender-row-toggle')];
|
||||
}
|
||||
|
||||
function expectExpandedInline(container: HTMLElement, button: HTMLButtonElement) {
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(button.querySelector('.triangle-icon-down')).not.toBeNull();
|
||||
const detailRow = button.closest('tr')?.nextElementSibling;
|
||||
expect(detailRow?.classList.contains('defender-detail-row')).toBe(true);
|
||||
expect(detailRow?.querySelector(`#${button.getAttribute('aria-controls')}`)).not.toBeNull();
|
||||
expect(container.querySelectorAll('.defender-view > .defender-table-wrap > .defender-table > tbody > .defender-detail-row')).toHaveLength(1);
|
||||
}
|
||||
|
||||
function exerciseView(container: HTMLElement, buttons: HTMLButtonElement[]) {
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0].querySelector('.triangle-icon-right')).not.toBeNull();
|
||||
act(() => buttons[0].click());
|
||||
expectExpandedInline(container, buttons[0]);
|
||||
act(() => buttons[1].click());
|
||||
expect(buttons[0].getAttribute('aria-expanded')).toBe('false');
|
||||
expectExpandedInline(container, buttons[1]);
|
||||
act(() => buttons[1].click());
|
||||
expect(container.querySelector('.defender-detail-row')).toBeNull();
|
||||
}
|
||||
|
||||
describe('Defender inline drilldowns', () => {
|
||||
it('expands, moves, and collapses details beside CVE, device, and site rows', () => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<DefenderVulnerabilityPanel dataset={dataset()} />));
|
||||
|
||||
const cveListHeaders = [...container.querySelectorAll('.defender-view > .defender-table-wrap > .defender-table > thead th')].map(header => header.textContent);
|
||||
expect(cveListHeaders).toEqual(['CVE', 'Severity', 'Affected devices', 'Sites', 'Sensors']);
|
||||
const cveButtons = toggleButtons(container);
|
||||
exerciseView(container, cveButtons);
|
||||
act(() => cveButtons[0].click());
|
||||
expect(container.querySelector('.defender-detail-row')?.textContent).toContain('Synthetic description A');
|
||||
expect(container.querySelector('#defender-cve-detail-0 details')).toBeNull();
|
||||
const evidenceHeaders = [...container.querySelectorAll('#defender-cve-detail-0 > .defender-table-wrap > .defender-table > thead th')].map(header => header.textContent);
|
||||
expect(evidenceHeaders).toEqual(['Device', 'IP', 'Site', 'Sensor']);
|
||||
expect(container.querySelectorAll('#defender-cve-detail-0 > .defender-table-wrap > .defender-table > tbody > tr')).toHaveLength(2);
|
||||
act(() => cveButtons[0].click());
|
||||
|
||||
const devicesTab = [...container.querySelectorAll<HTMLButtonElement>('.defender-tabs button')].find(button => button.textContent === 'Devices');
|
||||
act(() => devicesTab?.click());
|
||||
const deviceButtons = toggleButtons(container);
|
||||
exerciseView(container, deviceButtons);
|
||||
act(() => deviceButtons[0].click());
|
||||
const deviceCveHeaders = [...container.querySelectorAll('#defender-device-detail-0 > .defender-table-wrap > .defender-table > thead th')].map(header => header.textContent);
|
||||
expect(deviceCveHeaders).toEqual(['CVE', 'Severity']);
|
||||
act(() => deviceButtons[0].click());
|
||||
|
||||
const sitesTab = [...container.querySelectorAll<HTMLButtonElement>('.defender-tabs button')].find(button => button.textContent === 'Sites');
|
||||
act(() => sitesTab?.click());
|
||||
exerciseView(container, toggleButtons(container));
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
@ -136,11 +136,11 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
|
|||
expect(imported.extraction?.candidates).toHaveLength(1);
|
||||
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(3);
|
||||
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Device Count')?.originalValue).toBe(2);
|
||||
// The affected devices are carried as a markdown table for the result popup, one row per device with its scanner and site.
|
||||
// The affected devices are carried as a markdown table for the result popup, one row per device with its name, IP address, scanner and site.
|
||||
const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? '';
|
||||
expect(devicesTable).toContain('| Affected Device | Scanner | Site |');
|
||||
expect(devicesTable).toContain('00:11:22:33:44:55');
|
||||
expect(devicesTable).toContain('device-b');
|
||||
expect(devicesTable).toContain('| Name | IP Address | Scanner | Site |');
|
||||
expect(devicesTable).toContain('| 00:11:22:33:44:55 | 10.0.0.1 |');
|
||||
expect(devicesTable).toContain('| device-b | 10.0.0.2 |');
|
||||
expect(imported.mapping?.candidates).toHaveLength(1);
|
||||
expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ const payload = {
|
|||
descriptions: [{ lang: 'en', value: 'Synthetic vulnerability description.' }],
|
||||
metrics: {
|
||||
cvssMetricV31: [{ source: 'nvd@nist.gov', type: 'Primary', exploitabilityScore: 3.9, impactScore: 5.9,
|
||||
cvssData: { version: '3.1', vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', baseScore: 9.8, baseSeverity: 'CRITICAL' } }]
|
||||
cvssData: { version: '3.1', vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', baseScore: 9.8, baseSeverity: 'CRITICAL' } }],
|
||||
cvssMetricV2: [{ source: 'nvd@nist.gov', type: 'Primary', baseSeverity: 'MEDIUM', exploitabilityScore: 8.6, impactScore: 2.9,
|
||||
cvssData: { version: '2.0', vectorString: 'AV:N/AC:M/Au:N/C:P/I:N/A:N', baseScore: 4.3 } }]
|
||||
},
|
||||
weaknesses: [{ source: 'nvd@nist.gov', type: 'Primary', description: [{ lang: 'en', value: 'CWE-119' }] }],
|
||||
references: [{ url: 'https://vendor.example/advisory', source: 'nvd@nist.gov', tags: ['Vendor Advisory', 'Patch'] }],
|
||||
|
|
@ -28,7 +30,10 @@ describe('NVD CVE enrichment', () => {
|
|||
const record = parseNvdCveResponse(payload, 'CVE-2012-3037', '2026-09-10T08:00:00.000Z');
|
||||
expect(record).toMatchObject({
|
||||
cveId: 'CVE-2012-3037', source: 'nvd', description: 'Synthetic vulnerability description.',
|
||||
metrics: [{ version: '3.1', baseScore: 9.8, baseSeverity: 'CRITICAL' }],
|
||||
metrics: [
|
||||
{ version: '3.1', baseScore: 9.8, baseSeverity: 'CRITICAL' },
|
||||
{ version: '2.0', baseScore: 4.3, baseSeverity: 'MEDIUM' }
|
||||
],
|
||||
weaknesses: [{ id: 'CWE-119' }],
|
||||
references: [{ url: 'https://vendor.example/advisory', tags: ['Vendor Advisory', 'Patch'] }],
|
||||
affectedConfigurations: [{
|
||||
|
|
|
|||
Loading…
Reference in New Issue