feat(defender-for-iot): enhance vulnerability dashboard with new layout and outcome marking

This commit is contained in:
Binh 2026-09-11 12:54:30 +02:00
parent 0d648c1925
commit b28f260165
8 changed files with 100 additions and 24 deletions

View File

@ -2,6 +2,8 @@
Status: lebendes Projektdokument
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.
Client-Info-Security-Domain-Mengenbalken 2026-09-20 (Phase 7, ausdrueckliche Nutzerentscheidung und nachfolgende Ausrichtungs-/Farbkorrekturen, umgesetzt und verifiziert): Jede angezeigte Security-Domain-Zeile traegt einen dezenten Mengenbalken in einer eigenen gemeinsamen Rasterspalte zwischen dem laengsten gerenderten Domainnamen und der breitesten gerenderten Mengenspalte. Dadurch liegen beidseitig 12 px Abstand; kein Balken kann in Schrift oder hinter eine Zahl wachsen. Alle Balken enden an derselben Kante und wachsen von dort nach links. Seine Breite entspricht dem sichtbaren Abweichungs- beziehungsweise Review-only-Wert der Zeile geteilt durch die Summe aller angezeigten Domainwerte; die exakte rechtsbuendige Zahl bleibt die autoritative sichtbare Angabe. Die spaetere Nutzerkorrektur ersetzt die kurzzeitig gewaehlte kategoriale `--viz-*`-Folge: Jeder Balken liest nun exakt denselben aktuellen semantischen Farbwert wie der Overall-Score-Donut und wechselt gemeinsam mit dessen Critical-, Medium-, Passed- oder Unknown-Tone. Die Balken sind rein dekorativ und fuer assistive Technik verborgen. Vier fokussierte Suites mit 23 Tests, die Designsprachpruefung sowie Client-/Server-Produktionsbuild bestehen. Die Live-Pruefung am freigegebenen Dummy bestaetigt fuer alle neun Zeilen getrennte Text-/Balken-/Zahlenspalten, beide 12-px-Abstaende, identische Donut-/Balkenfarbe `rgb(232, 33, 79)` und keinen horizontalen Ueberlauf in Light und Dark; Light wurde wiederhergestellt. Keine Daten-, Klassifikations-, Score-, Drilldown-, API- oder Persistenzaenderung.

View File

@ -1,8 +1,8 @@
import { memo } from 'react';
import { memo, useEffect, useRef } from 'react';
import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { toolIdForRecord } from '../../importers/status-normalization';
import { findingOutcomeMark, resultStatusFieldFor } from '../../features/linked-results/result-message';
import { policyDiagnosticBadges } from '../../profiles/group-policy/categories';
import { highlightText } from '../HighlightText/highlight-text';
@ -24,20 +24,26 @@ interface Props {
function FindingCardComponent({ record, title, bookmarked, selected, toolId, search, onOpen }: Props) {
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const severity = normalizeSeverity(severityField?.value);
const statusField = findingCardStatus(record, toolId);
const outcomeMark = findingOutcomeMark(record, toolId);
const resolvedToolId = toolIdForRecord(record, toolId);
const outcome = outcomeForStatusValue(resolvedToolId, statusField?.value);
const diagnostics = policyDiagnosticBadges(record, resolvedToolId);
const cardRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!selected) return;
cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}, [selected]);
return (
<button
ref={cardRef}
aria-current={selected ? 'true' : undefined}
className={`record-card severity-${severity}${bookmarked ? ' is-workspace' : ''}${selected ? ' active' : ''}`}
type="button"
onClick={onOpen}
>
<span className="record-title">{highlightText(title, search)}{bookmarked && <span className="action-pill">Workspace</span>}</span>
{(statusField || diagnostics.length > 0) && <span className="record-meta-line">
{statusField && <span className="record-meta outcome-mark" data-outcome={outcome}>{highlightText(statusField.value, search)}</span>}
{(outcomeMark || diagnostics.length > 0) && <span className="record-meta-line">
{outcomeMark && <span className="record-meta outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{diagnostics.map(diagnostic => <span className="record-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={diagnostic}>{diagnostic}</span>)}
</span>}

View File

@ -54,9 +54,13 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
const affectedDevices = model.devices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length;
const occurrenceSeverity = Object.fromEntries(severities.map(item => [item, model.occurrences.filter(occurrence => occurrence.severity === item).length])) as Record<DefenderSeverity, number>;
return <div className="defender-page">
return <div className="dashboard-page client-command-center defender-page">
{navigation}
<p className="defender-context"><strong>Microsoft Defender for IoT vulnerabilities</strong><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span><WorkTimeslotControl /></p>
<header className="client-overview-header defender-header">
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
<div className="client-overview-source defender-source"><span>Source:</span><strong>Microsoft Defender for IoT</strong></div>
</header>
<section className="defender-metrics" aria-label="Vulnerability metrics">
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article>
<article><span>Unique CVEs</span><strong><Count value={model.cves.length} /></strong></article>
@ -65,6 +69,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
</section>
<p className="defender-context"><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span></p>
<nav className="defender-tabs" aria-label="Defender vulnerability views">
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
</nav>

View File

@ -16,7 +16,7 @@ import { isFileReference } from '../../profiles/purple-knight/result-linker';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { findingOutcomeMark, resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories';
@ -337,6 +337,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const outcomeMark = findingOutcomeMark(record, toolId);
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
const policySearchKeys = policyAnalyzerSearchKeys(record, title, resolvedToolId);
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
@ -362,7 +363,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
<div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, search)}</span>}
{outcomeMark && <span className="outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity';

View File

@ -1,7 +1,7 @@
import type { FindingRecord, RecordField } from '../../domain/finding';
import type { DatasetSheet } from '../../domain/dataset';
import type { CanonicalOutcome } from '../../domain/canonical-model';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
@ -181,6 +181,37 @@ export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: str
}
return undefined;
}
/** Menschlich lesbare Marke je kanonischem Outcome (Rueckfall ohne Quellwort). */
const OUTCOME_MARK_LABELS: Record<CanonicalOutcome, string> = {
fail: 'Failed', pass: 'Passed', warning: 'Warning', informational: 'Informational',
execution_error: 'Error', not_run: 'Not run', not_applicable: 'Not applicable',
not_evaluated: 'Not evaluated', unknown: 'Unknown'
};
export interface FindingOutcomeMark {
outcome: CanonicalOutcome;
label: string;
}
/**
* Outcome-Marke eines rohen Work-Datensatzes fuer die kompakten Kartenlisten
* und den Reader-Kopf. Fuehrt das Werkzeug ein eigenes Statuswort, bleibt dieses
* die Beschriftung. Liefert ein Werkzeug seine Funde ohne Statusspalte (jede
* Defender-CVE ist eine Abweichung), traegt das Profil den Ausfall-Outcome
* (`normalization.missingStatusOutcome`) nach, damit die Liste nicht ohne
* Outcome-Hinweis bleibt. Werkzeuge ohne deklarierten Ausfall-Outcome behalten
* das bisherige Verhalten - ohne Statusfeld keine Marke.
*/
export function findingOutcomeMark(record: FindingRecord, fallbackToolId?: string): FindingOutcomeMark | undefined {
const toolId = toolIdForRecord(record, fallbackToolId);
const statusField = resultStatusFieldFor(record, fallbackToolId);
if (statusField) {
return { outcome: outcomeForStatusValue(toolId, statusField.value) ?? 'unknown', label: statusField.value };
}
const outcome = normalizeStatusDimensionsFor(toolId, '').outcome;
return outcome === 'unknown' ? undefined : { outcome, label: OUTCOME_MARK_LABELS[outcome] };
}
/**
* Felder, die als eigener benannter Abschnitt in das Results-Fenster gehoeren
* (siehe `resultSectionHeaders` im Toolprofil).

View File

@ -1,21 +1,21 @@
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--segment-gap); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--client-info-text); background: var(--surface-app); }
.defender-header { order: 0; }
.defender-source strong { color: var(--client-info-text); font-weight: var(--fw-regular); }
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
.defender-context strong { color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.defender-context .work-timeslot-select { margin-left: auto; }
.defender-metrics { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.defender-metrics article { display: grid; align-content: space-between; gap: var(--space-3); min-height: 128px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
.defender-metrics span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
.defender-metrics strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-regular); font-variant-numeric: tabular-nums; }
.defender-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); justify-content: start; gap: var(--space-2); min-width: 0; }
.defender-metrics article { display: grid; align-content: center; gap: var(--space-2); min-width: 0; min-height: 88px; padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); box-shadow: none; }
.defender-metrics span { color: var(--client-info-muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
.defender-metrics strong { color: var(--client-info-text); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; line-height: 1; }
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); }
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--tile-surface); }
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--client-info-surface); }
.defender-table { width: 100%; border-collapse: collapse; table-layout: auto; color: var(--text); background: var(--tile-surface); font-size: var(--type-table-body-size); }
.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; }
@ -28,7 +28,7 @@
.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: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
.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 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); }
@ -42,4 +42,6 @@
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
@media (max-width: 760px) { .defender-view > header { display: grid; } .defender-context .work-timeslot-select { margin-left: 0; } }
@container (max-width: 1100px) { .defender-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@container (max-width: 760px) { .defender-view > header { display: grid; } .defender-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container (max-width: 480px) { .defender-metrics { grid-template-columns: 1fr; } }

View File

@ -48,10 +48,15 @@ describe('Defender vulnerability dashboard projection', () => {
expect(component).toContain('Site dashboard');
expect(component).toContain('Source row');
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"');
expect(component).toContain('<span>Source:</span><strong>Microsoft Defender for IoT</strong>');
expect(dashboard).toContain("['overview', 'Overview']");
expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']");
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
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).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

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { promotedResultSheetTitlesFor, resultMessageFor, resultSectionsFor, resultSheetRowHighlightedFor, resultSheetTitleFor, resultStatusFieldFor, showResultSheetTitlesFor } from '../../src/features/linked-results/result-message';
import { findingOutcomeMark, promotedResultSheetTitlesFor, resultMessageFor, resultSectionsFor, resultSheetRowHighlightedFor, resultSheetTitleFor, resultStatusFieldFor, showResultSheetTitlesFor } from '../../src/features/linked-results/result-message';
function maesterRecord(fields: Array<[string, string]>): FindingRecord {
const built = fields.map(([label, value], index) => ({ label, value, index }));
@ -148,6 +148,30 @@ describe('resultStatusFieldFor', () => {
expect(resultStatusFieldFor(record, 'purple-knight')).toMatchObject({ label: 'Status', value: expected });
});
describe('findingOutcomeMark', () => {
it('traegt bei Defender-Schwachstellen ohne Statusspalte den Ausfall-Outcome nach', () => {
const record = maesterRecord([
['CVE', 'CVE-2026-25786'],
['Severity', 'Critical'],
['Beschreibung', 'Synthetic description']
]);
expect(findingOutcomeMark(record, 'defender-for-iot-vulnerabilities')).toEqual({ outcome: 'warning', label: 'Warning' });
});
it('behaelt das Quell-Statuswort, wo das Werkzeug eine Statusspalte fuehrt', () => {
const record = maesterRecord([['Status', 'IOE Found'], ['Result', 'SI000067']]);
expect(findingOutcomeMark(record, 'purple-knight')).toEqual({ outcome: 'fail', label: 'Failed' });
});
it('bleibt ohne Statusfeld und ohne deklarierten Ausfall-Outcome leer', () => {
const record = maesterRecord([['ID', 'MT.1011'], ['Name', 'Some finding']]);
expect(findingOutcomeMark(record, 'maester')).toBeUndefined();
});
});
describe('promotedResultSheetTitlesFor', () => {
it('ordnet mehrere Controls derselben Framework-Ueberschrift ihren Tabellen in Reihenfolge zu', () => {
const sections = [{