diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index cc21a48..3bb79f7 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2,6 +2,8 @@ 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. 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. diff --git a/src/components/FindingCard/FindingCard.tsx b/src/components/FindingCard/FindingCard.tsx index 749244b..c2d9c4c 100644 --- a/src/components/FindingCard/FindingCard.tsx +++ b/src/components/FindingCard/FindingCard.tsx @@ -1,8 +1,7 @@ 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 { findingOutcomeMark, resultStatusFieldFor } from '../../features/linked-results/result-message'; import { highlightText } from '../HighlightText/highlight-text'; @@ -23,8 +22,7 @@ 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 outcome = outcomeForStatusValue(toolIdForRecord(record, toolId), statusField?.value); + const outcomeMark = findingOutcomeMark(record, toolId); const cardRef = useRef(null); useEffect(() => { @@ -41,7 +39,7 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea onClick={onOpen} > {highlightText(title, search)}{bookmarked && Workspace} - {statusField && {highlightText(statusField.value, search)}} + {outcomeMark && {highlightText(outcomeMark.label, search)}} ); diff --git a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx index 0c312a8..8d7e3db 100644 --- a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx +++ b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx @@ -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; - return
+ return
{navigation} -

Microsoft Defender for IoT vulnerabilities{model.summaryRowCount.toLocaleString('en-US')} source asset rows{model.diagnostics.length.toLocaleString('en-US')} diagnostics

+
+
Client:{dataset.fileName}
+
Assessment:
+
Source:Microsoft Defender for IoT
+
Assets
Unique CVEs
@@ -65,6 +69,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
Raw sensor observations
Aggregate-only vulnerabilities
+

{model.summaryRowCount.toLocaleString('en-US')} source asset rows{model.diagnostics.length.toLocaleString('en-US')} diagnostics

diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index c5ac28c..2a3e1e2 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -13,7 +13,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 { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation'; 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 { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = 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); // Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr // Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten @@ -308,7 +309,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist `Investigate` optisch nicht von `Passed` zu unterscheiden. */} - {statusField?.value && {highlightText(statusField.value, search)}} + {outcomeMark && {highlightText(outcomeMark.label, search)}} {derivedCategories.map(category => {highlightText(category, search)})} {badges.map(field => { const isSeverity = field.label.toLowerCase() === 'severity'; diff --git a/src/features/linked-results/result-message.ts b/src/features/linked-results/result-message.ts index c4e2785..b214d0f 100644 --- a/src/features/linked-results/result-message.ts +++ b/src/features/linked-results/result-message.ts @@ -1,6 +1,7 @@ import type { FindingRecord, RecordField } from '../../domain/finding'; 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 { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error'; @@ -169,6 +170,37 @@ export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: str } return undefined; } + +/** Menschlich lesbare Marke je kanonischem Outcome (Rueckfall ohne Quellwort). */ +const OUTCOME_MARK_LABELS: Record = { + 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). diff --git a/src/styles/defender-for-iot.css b/src/styles/defender-for-iot.css index 9102034..f50b820 100644 --- a/src/styles/defender-for-iot.css +++ b/src/styles/defender-for-iot.css @@ -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; } } diff --git a/tests/features/defender-dashboard.test.ts b/tests/features/defender-dashboard.test.ts index b034bc4..3643c87 100644 --- a/tests/features/defender-dashboard.test.ts +++ b/tests/features/defender-dashboard.test.ts @@ -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('Source:Microsoft Defender for IoT'); expect(dashboard).toContain("['overview', 'Overview']"); expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']"); expect(dashboard).toContain(''); 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'); diff --git a/tests/features/result-message.test.ts b/tests/features/result-message.test.ts index f7715e5..bfca21e 100644 --- a/tests/features/result-message.test.ts +++ b/tests/features/result-message.test.ts @@ -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 = [{