diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index a2455aa..79126c3 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): 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. diff --git a/src/components/FindingCard/FindingCard.tsx b/src/components/FindingCard/FindingCard.tsx index 305316b..da3dccc 100644 --- a/src/components/FindingCard/FindingCard.tsx +++ b/src/components/FindingCard/FindingCard.tsx @@ -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(null); + + useEffect(() => { + if (!selected) return; + cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); + }, [selected]); return ( )} diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index 66a2962..c1bed99 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -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
{/* 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 bb27dc1..29bb87e 100644 --- a/src/features/linked-results/result-message.ts +++ b/src/features/linked-results/result-message.ts @@ -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 = { + 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 = [{