fix: Adjust NvdCveClient to correctly handle baseSeverity from CVSS metrics
This commit is contained in:
parent
b28f260165
commit
ae0e4b86dd
|
|
@ -11,6 +11,7 @@ vite.config.js
|
|||
vite.config.d.ts
|
||||
*.png
|
||||
artifacts/objects/
|
||||
Mock-Defender/
|
||||
|
||||
.env.postgres
|
||||
.assessor-data/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Status: lebendes Projektdokument
|
||||
|
||||
Defender-for-IoT-Drilldowns 2026-09-11 (Phase 7): CVE-, Device- und Site-Details erscheinen inline unter der ausgewaehlten Zeile mit stabilen, zugaenglichen Auf-/Zuklappzielen und gemeinsamer gelber Auswahlmarkierung. CVE-Evidenz zeigt betroffene Geraete flach; redundante Beschreibungs-, Observation- und Sensorangaben bleiben in den jeweils passenden Detailkontexten statt in der Uebersicht. Die Detailflaeche verwendet die gemeinsame Tile-Oberflaeche. Der NVD-Adapter uebernimmt `baseSeverity` aus den CVSS-Metriken. Reine Darstellungs-/Adapterkorrektur ohne Persistenz- oder Reimportaenderung.
|
||||
|
||||
Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit CVE-, Device- und Site-Ansichten. Sie verwendet denselben themenabhaengigen Client-Info-Canvas, dieselbe 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.
|
||||
|
||||
Maester-Unicode-Null-Import 2026-09-20 (Phase 13A/13C, Nutzerfehlerbericht, umgesetzt und verifiziert): Ein nativer Maester-Fehlertext enthielt `U+0000`. Vorschau und Mapping waren erfolgreich, der atomare PostgreSQL-Abschluss scheiterte jedoch, weil `jsonb` dieses Zeichen nicht repraesentieren kann; die bisherige Auto-Import-Fehlerbehandlung ersetzte danach die erfolgreiche Vorschau irrefuehrend durch `0 of 0 files selected`. Die gemeinsame kanonische Importgrenze stellt jedes solche Zeichen nun explizit als lesbares `\\u0000` dar, behaelt den gesamten umgebenden Quellwert, Hash und Locator und erzeugt die Diagnose `unicode-null-escaped`. Andere Steuerzeichen, Parsing, Profilwahl, Normalisierung und Bewertung bleiben unveraendert. Nach einem Abschlussfehler bleibt die vorbereitete Dateizeile samt Wiederholungsmoeglichkeit sichtbar. Ein synthetischer Maester-XLSX-Test und der vollstaendige HTTP-Pfad Vorschau -> bestaetigte Zuordnung -> Import in vorhandenen Timeslot sichern genau die Regression; 26 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestehen. Der optionale PostgreSQL-Testlauf wurde gemaess kanonischer-Datastore-Regel nicht gegen die bestehende Kundendatenbank ausgefuehrt. Keine Migration, Schema- oder Bestandsdatenaenderung; Phase 13A bleibt abgeschlossen, Phase 13C in Arbeit.
|
||||
|
|
|
|||
|
|
@ -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