fix: Adjust NvdCveClient to correctly handle baseSeverity from CVSS metrics

This commit is contained in:
Binh 2026-09-11 16:03:47 +02:00
parent 08771d5cbb
commit b438f25fd8
10 changed files with 182 additions and 42 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ vite.config.js
vite.config.d.ts vite.config.d.ts
*.png *.png
artifacts/objects/ artifacts/objects/
Mock-Defender/
.env.postgres .env.postgres
.assessor-data/ .assessor-data/

View File

@ -2,6 +2,18 @@
Status: lebendes Projektdokument 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. 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. 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.

View File

@ -63,12 +63,14 @@ function metrics(value: unknown): CveMetric[] {
const baseScore = number(data?.baseScore); const baseScore = number(data?.baseScore);
const vector = string(data?.vectorString); const vector = string(data?.vectorString);
if (!item || !data || baseScore === undefined || !vector) return []; 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 [{ return [{
version, version,
source: string(item.source) || 'unknown', source: string(item.source) || 'unknown',
...(string(item.type) ? { type: string(item.type) } : {}), ...(string(item.type) ? { type: string(item.type) } : {}),
baseScore, baseScore,
...(string(data.baseSeverity) ? { baseSeverity: string(data.baseSeverity) } : {}), ...(baseSeverity ? { baseSeverity } : {}),
vector, vector,
...(number(item.exploitabilityScore) === undefined ? {} : { exploitabilityScore: number(item.exploitabilityScore) }), ...(number(item.exploitabilityScore) === undefined ? {} : { exploitabilityScore: number(item.exploitabilityScore) }),
...(number(item.impactScore) === undefined ? {} : { impactScore: number(item.impactScore) }) ...(number(item.impactScore) === undefined ? {} : { impactScore: number(item.impactScore) })

View File

@ -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 { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
import type { ImportedDataset } from '../../domain/dataset'; import type { ImportedDataset } from '../../domain/dataset';
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl'; 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>; 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)); 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> <h3>{device.displayName || 'Unnamed device'}</h3>
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p> <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"> <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>)} {severities.map(item => <span key={item}><strong>{label(item)}</strong><Measure measure={device.severity[item]} /></span>)}
</div> </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.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><td>{item.description || 'Description unavailable'}</td><td>{item.sensors.join(', ') || 'Sensor unavailable'}</td></tr>)} {values.map(item => <tr key={item.id}><td>{item.cve}</td><td><SeverityMark severity={item.severity} /></td></tr>)}
</tbody></table> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>} </tbody></table></div> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>}
</div>; </div>;
} }
@ -48,9 +49,6 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
const [selectedCve, setSelectedCve] = useState(''); const [selectedCve, setSelectedCve] = useState('');
const [selectedDevice, setSelectedDevice] = useState(''); const [selectedDevice, setSelectedDevice] = useState('');
const [selectedSite, setSelectedSite] = 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 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>; 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"> {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> <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> <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 => <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>)} {model.cves.map((cve, index) => {
</tbody></table></div> const expanded = selectedCve === cve.id;
{currentCve && <div className="defender-drilldown"><h3>{currentCve.id}</h3><p>{currentCve.description || 'Description unavailable'}</p> const detailId = `defender-cve-detail-${index}`;
{(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>} 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> <h4>Affected devices and source observations</h4>
{model.occurrences.filter(item => item.cve === currentCve.id).map(occurrence => { <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>
const device = model.devices.find(item => item.id === occurrence.deviceId); </div></td></tr>}
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> </Fragment>;
<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>} </tbody></table></div>
</section>} </section>}
{view === 'devices' && <section className="defender-view" aria-labelledby="defender-devices-heading"> {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> <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> <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> </tbody></table></div>
{currentDevice && <DeviceCves device={currentDevice} occurrences={model.occurrences} />}
</section>} </section>}
{view === 'sites' && <section className="defender-view" aria-labelledby="defender-sites-heading"> {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> <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> <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> <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> </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>} </section>}
</div>; </div>;
} }

View File

@ -50,10 +50,6 @@ function field(key: string, label: string, originalValue: JsonValue, decodedValu
return { key, label, originalValue, decodedValue, source }; 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 { function cell(value: string): string {
// Zellen einer Markdown-Tabelle: Pipe und Zeilenumbruch wuerden die // Zellen einer Markdown-Tabelle: Pipe und Zeilenumbruch wuerden die
// Tabellenstruktur brechen, deshalb neutralisiert. // Tabellenstruktur brechen, deshalb neutralisiert.
@ -63,10 +59,15 @@ function cell(value: string): string {
// Die betroffenen Devices je CVE als Markdown-Tabelle. Sie wird im // Die betroffenen Devices je CVE als Markdown-Tabelle. Sie wird im
// Ergebnisfenster als Tabelle ausgewertet (siehe RichTextValue) und ersetzt so // Ergebnisfenster als Tabelle ausgewertet (siehe RichTextValue) und ersetzt so
// die frueher als Rohtext gezeigten Source Observations. Eine Zeile je Device // 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 { function affectedDevicesTable(occurrences: DefenderOccurrence[], deviceById: Map<string, DefenderDevice>): string {
const header = '| Affected Device | Scanner | Site |\n| --- | --- | --- |'; const header = '| Name | IP Address | 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 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'); return [header, ...rows].join('\n');
} }

View File

@ -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 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 { 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 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: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: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-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 { 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-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
.defender-conflict, .defender-warning { color: var(--warning); } .defender-conflict, .defender-warning { color: var(--warning); }
.defender-unavailable, .defender-empty { color: var(--muted); } .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, .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 h3 { font-size: var(--type-detail-title-size); }
.defender-drilldown h4 { font-size: var(--type-h4-size); } .defender-drilldown h4 { font-size: var(--type-h4-size); }

View File

@ -46,7 +46,7 @@ describe('Defender vulnerability dashboard projection', () => {
expect(component).toContain('CVE view'); expect(component).toContain('CVE view');
expect(component).toContain('Device view'); expect(component).toContain('Device view');
expect(component).toContain('Site dashboard'); 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('aggregate-only');
expect(component).toContain('className="dashboard-page client-command-center defender-page"'); expect(component).toContain('className="dashboard-page client-command-center defender-page"');
expect(component).toContain('className="client-overview-header defender-header"'); 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('padding: var(--panel-inset)');
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))'); expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
expect(styles).toContain('background: var(--tile-surface)'); 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(--marking-color)');
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)'); expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
expect(styles).not.toContain('text-overflow: ellipsis'); expect(styles).not.toContain('text-overflow: ellipsis');

View File

@ -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());
});
});

View File

@ -136,11 +136,11 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
expect(imported.extraction?.candidates).toHaveLength(1); 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 === 'Observation Count')?.originalValue).toBe(3);
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Device Count')?.originalValue).toBe(2); 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 ?? ''; const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? '';
expect(devicesTable).toContain('| Affected Device | Scanner | Site |'); expect(devicesTable).toContain('| Name | IP Address | Scanner | Site |');
expect(devicesTable).toContain('00:11:22:33:44:55'); expect(devicesTable).toContain('| 00:11:22:33:44:55 | 10.0.0.1 |');
expect(devicesTable).toContain('device-b'); expect(devicesTable).toContain('| device-b | 10.0.0.2 |');
expect(imported.mapping?.candidates).toHaveLength(1); expect(imported.mapping?.candidates).toHaveLength(1);
expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]); expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]);

View File

@ -9,7 +9,9 @@ const payload = {
descriptions: [{ lang: 'en', value: 'Synthetic vulnerability description.' }], descriptions: [{ lang: 'en', value: 'Synthetic vulnerability description.' }],
metrics: { metrics: {
cvssMetricV31: [{ source: 'nvd@nist.gov', type: 'Primary', exploitabilityScore: 3.9, impactScore: 5.9, 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' }] }], 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'] }], 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'); const record = parseNvdCveResponse(payload, 'CVE-2012-3037', '2026-09-10T08:00:00.000Z');
expect(record).toMatchObject({ expect(record).toMatchObject({
cveId: 'CVE-2012-3037', source: 'nvd', description: 'Synthetic vulnerability description.', 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' }], weaknesses: [{ id: 'CWE-119' }],
references: [{ url: 'https://vendor.example/advisory', tags: ['Vendor Advisory', 'Patch'] }], references: [{ url: 'https://vendor.example/advisory', tags: ['Vendor Advisory', 'Patch'] }],
affectedConfigurations: [{ affectedConfigurations: [{