feat(defender-for-iot): inline drill-downs in the vulnerability panel

Partial pick of b438f25. CVE, device and site rows expand their details
in place; the Affected Devices table lists name and IP separately.

Unlike the original, the expanded row uses the blue table-row marking
tokens and the drill-down keeps the shared tile tokens. The NVD client
fix is left out with the skipped CVE enrichment.
This commit is contained in:
Binh 2026-10-02 13:22:58 +02:00
parent e0067311f3
commit 8beea97ce4
7 changed files with 159 additions and 38 deletions

View File

@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
| 2026-10-02 | Phasen 6/7: Defender-Teile von `b438f25` uebernommen (nach `c7c716c`, weil der Commit zunaechst nur als NVD-Korrektur eingeordnet war). CVE-, Device- und Site-Tabelle des Defender-Panels klappen ihre Details direkt unter der angeklickten Zeile auf und wieder zu (Dreieck-Schalter, `aria-expanded`/`aria-controls`); die CVE-Tabelle fuehrt die Beschreibung nur noch im Detail. Der Extractor gibt die Tabelle `Affected Devices` als `Name`, `IP Address`, `Scanner`, `Site` aus. Abweichend vom Original nutzt die aufgeklappte Zeile nach Nutzerentscheidung die blaue Tabellenzeilen-Markierung (`--table-row-marking`, `--table-row-marking-surface`) statt Gelb, und der Drilldown bleibt auf den gemeinsamen Kachelrollen. Nicht uebernommen: die `baseSeverity`-Korrektur im NVD-Client samt Test (CVE-Anreicherung ausgelassen) und der `.gitignore`-Eintrag. Kein Profilversionssprung wie im Original; die Tabellenspalten liegen bereits unter dem danach uebernommenen Profil 1.2.0, die lokale Datenbank enthaelt keine Defender-Importe. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.896 bestandenen und 17 uebersprungenen Tests (ein lastbedingter Einzelausfall in `node-import-engine.test.ts` im ersten Lauf, allein und im Wiederholungslauf bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 13A: Aus `33d2019` nur die Dokumentation uebernommen. `docs/CANONICAL_MODEL.md` beschreibt `vulnerability_finding` als additive ASSESSOR-Erweiterung des externen `result_type`-Katalogs; der Kommentar an `CanonicalResultType` verweist darauf. Nicht uebernommen: die Backfill-Migration (frueher 023) samt Katalogeintrag und Schema-Assertions, weil die neu angelegte lokale Datenbank keine Defender-Occurrences enthaelt, sowie auf Nutzerentscheidung der lesende Outcome-Fallback in `unified-result.ts` mit seinem Test; neue Importe erhalten `warning` bereits ueber `missingStatusOutcome`. Keine Verhaltens-, API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |

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

View File

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

View File

@ -20,9 +20,13 @@
.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(--table-row-marking-surface); }
.defender-selected-row > td:first-child { box-shadow: inset 4px 0 var(--table-row-marking); }
.defender-detail-row > td { max-width: none; padding: 0 0 var(--space-3); border-bottom-color: var(--table-row-marking); }
.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)); }

View File

@ -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');

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

@ -202,11 +202,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]);