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 c056c21006
commit 08771d5cbb
8 changed files with 91 additions and 22 deletions

View File

@ -2,6 +2,8 @@
Status: lebendes Projektdokument Status: lebendes Projektdokument
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.
Purple-Knight-Scorezuordnung 2026-09-10 (Phase 7/13C, Nutzerkorrektur): Eine native score-only Assessment Summary und die eindeutige Findings-Datei desselben Scanlaufs werden nun ueber Assessment, Werkzeug, Scanlauf und Verarbeitungskontrakt verbunden. Der native Wert bleibt der Scorebeitrag; Passed-/Deviation-Zahlen stammen aus der Findings-Datei. Die Zuordnung scheitert geschlossen bei mehreren nativen Traegern oder mehrdeutigen Ergebnispopulationen. Die synthetische Regression mit nativ 65, 96 Passed und 19 Deviations liefert in der Dashboardprojektion 65 Native score statt Pass rate 83 und behaelt 19 Deviations. 1.199 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designchecks sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert. Purple-Knight-Scorezuordnung 2026-09-10 (Phase 7/13C, Nutzerkorrektur): Eine native score-only Assessment Summary und die eindeutige Findings-Datei desselben Scanlaufs werden nun ueber Assessment, Werkzeug, Scanlauf und Verarbeitungskontrakt verbunden. Der native Wert bleibt der Scorebeitrag; Passed-/Deviation-Zahlen stammen aus der Findings-Datei. Die Zuordnung scheitert geschlossen bei mehreren nativen Traegern oder mehrdeutigen Ergebnispopulationen. Die synthetische Regression mit nativ 65, 96 Passed und 19 Deviations liefert in der Dashboardprojektion 65 Native score statt Pass rate 83 und behaelt 19 Deviations. 1.199 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designchecks sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert.

View File

@ -1,8 +1,7 @@
import { memo, useEffect, useRef } from 'react'; import { memo, useEffect, useRef } from 'react';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation'; import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization'; import { findingOutcomeMark, resultStatusFieldFor } from '../../features/linked-results/result-message';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { highlightText } from '../HighlightText/highlight-text'; import { highlightText } from '../HighlightText/highlight-text';
@ -23,8 +22,7 @@ interface Props {
function FindingCardComponent({ record, title, bookmarked, selected, toolId, search, onOpen }: 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 severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const severity = normalizeSeverity(severityField?.value); const severity = normalizeSeverity(severityField?.value);
const statusField = findingCardStatus(record, toolId); const outcomeMark = findingOutcomeMark(record, toolId);
const outcome = outcomeForStatusValue(toolIdForRecord(record, toolId), statusField?.value);
const cardRef = useRef<HTMLButtonElement>(null); const cardRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
@ -41,7 +39,7 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
onClick={onOpen} onClick={onOpen}
> >
<span className="record-title">{highlightText(title, search)}{bookmarked && <span className="action-pill">Workspace</span>}</span> <span className="record-title">{highlightText(title, search)}{bookmarked && <span className="action-pill">Workspace</span>}</span>
{statusField && <span className="record-meta outcome-mark" data-outcome={outcome}>{highlightText(statusField.value, search)}</span>} {outcomeMark && <span className="record-meta outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
</button> </button>
); );

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 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>;
return <div className="defender-page"> return <div className="dashboard-page client-command-center defender-page">
{navigation} {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"> <section className="defender-metrics" aria-label="Vulnerability metrics">
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article> <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> <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>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> <article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
</section> </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"> <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>)} {([['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> </nav>

View File

@ -13,7 +13,7 @@ import { isFileReference } from '../../profiles/purple-knight/result-linker';
import { highlightText } from '../../components/HighlightText/highlight-text'; import { highlightText } from '../../components/HighlightText/highlight-text';
import { BookmarkIcon } from '../../components/BookmarkIcon'; import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon'; 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 { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation'; import { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation';
import { policyFindingCategories } from '../../profiles/group-policy/categories'; import { policyFindingCategories } from '../../profiles/group-policy/categories';
@ -285,6 +285,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
const webSearch = findingWebSearch(record, resolvedToolId); const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId); const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown'; 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 policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr // Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
// Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten // Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten
@ -308,7 +309,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
<div className="finding-badges"> <div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist {/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */} `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>)} {derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => { {badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity'; const isSeverity = field.label.toLowerCase() === 'severity';

View File

@ -1,6 +1,7 @@
import type { FindingRecord, RecordField } from '../../domain/finding'; import type { FindingRecord, RecordField } from '../../domain/finding';
import type { DatasetSheet } from '../../domain/dataset'; import type { DatasetSheet } from '../../domain/dataset';
import { toolIdForRecord } from '../../importers/status-normalization'; import type { CanonicalOutcome } from '../../domain/canonical-model';
import { normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog'; import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error'; import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
@ -169,6 +170,37 @@ export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: str
} }
return undefined; 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 * Felder, die als eigener benannter Abschnitt in das Results-Fenster gehoeren
* (siehe `resultSectionHeaders` im Toolprofil). * (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 { 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-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); justify-content: start; gap: var(--space-2); min-width: 0; }
.defender-context .work-timeslot-select { margin-left: auto; } .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 { 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 span { color: var(--client-info-muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
.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 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-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-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); } .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, .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: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: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-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 { 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 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-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 { 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 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; }
@ -28,7 +28,7 @@
.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: 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, .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); }
@ -42,4 +42,6 @@
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); } .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 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; } .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('Site dashboard');
expect(component).toContain('Source row'); expect(component).toContain('Source row');
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="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("['overview', 'Overview']");
expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']"); expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']");
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />'); expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
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('background: var(--tile-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

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding'; 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 { function maesterRecord(fields: Array<[string, string]>): FindingRecord {
const built = fields.map(([label, value], index) => ({ label, value, index })); 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 }); 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', () => { describe('promotedResultSheetTitlesFor', () => {
it('ordnet mehrere Controls derselben Framework-Ueberschrift ihren Tabellen in Reihenfolge zu', () => { it('ordnet mehrere Controls derselben Framework-Ueberschrift ihren Tabellen in Reihenfolge zu', () => {
const sections = [{ const sections = [{