diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 982718f..d345e59 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3652,6 +3652,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. | Datum | Änderung | +| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 13A: Aus `33d2019` nur die Dokumentation uebernommen. `docs/CANONICAL_MODEL.md` beschreibt `vulnerability_finding` als additive ASSESSOR-Erweiterung des externen `result_type`-Katalogs; der Kommentar an `CanonicalResultType` verweist darauf. Nicht uebernommen: die Backfill-Migration (frueher 023) samt Katalogeintrag und Schema-Assertions, weil die neu angelegte lokale Datenbank keine Defender-Occurrences enthaelt, sowie auf Nutzerentscheidung der lesende Outcome-Fallback in `unified-result.ts` mit seinem Test; neue Importe erhalten `warning` bereits ueber `missingStatusOutcome`. Keine Verhaltens-, API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/13A: `fe71246` uebernommen; `e048295` (NVD-CVE-Anreicherung) auf Nutzerentscheidung vorerst ausgelassen. Der Result-Contract kennt den Typ `vulnerability_finding`; Defender-Findings erhalten ihn. Ein Profil kann `missingStatusOutcome` deklarieren, Defender setzt `warning`, weil jede CVE-Zeile selbst der Befund ist. Das Defender-Profil steigt auf 1.1.0 mit Kompatibilitaetsschluessel `-v2` (semantic-breaking, Reimport erforderlich; die lokale Datenbank enthaelt keine Defender-Importe), 1.0.0 ist nicht mehr aktuell. Der Work-Filter `Deviation` aller Scanner nutzt statt der Regex-Liste `UNIVERSAL_DEVIATION_PATTERNS` nun `isDeviationRecord` und damit das kanonische Result-DB-Praedikat; die GPO-Statuslogik, der Statuscache und die uebrigen Statusfilter der Basis bleiben unveraendert davor. Alle bestehenden Work-, Filter- und Scannertests bestehen unveraendert; eine vollstaendige Gleichwertigkeit fuer jeden realen Statuswert ist damit nicht bewiesen. Migration `027_vulnerability_finding_result_type.sql` (frueher 022) erweitert nur den Check-Constraint und schreibt keine Occurrences um; ohne die ausgelassene CVE-Tabelle. Vor der Anwendung wurde das verifizierte Backup `backups/assessor-pre-027-20261002-123608.dump` erstellt. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/7/13A: `5fbbda2` uebernommen. Der Defender-Extractor erzeugt nun ein Finding je CVE statt je Device-mal-CVE und liefert `Affected Device Count` sowie die Tabelle `Affected Devices` (Device, Scanner, Site); das Profil bildet die Anzahl auf die Asset-Rolle ab, nutzt `Affected Devices` als Ergebnisabschnitt und vergleicht nur noch ueber die CVE. Der Finding Reader blendet das Rohfeld `Source Observations` fuer Defender aus, es bleibt als Provenienz im Datensatz. Nicht uebernommen: die Umbenennung der Asset-Zeile im Result-DB-Zeilendetail, weil die Disclosure-Policy `affectedAsset` fuer jede Zeile zurueckhaelt und eine Device-Anzahl dort nicht erscheinen soll. Die Finding-Identitaet aendert sich wie im Original ohne Profilversionssprung; die neu angelegte lokale Datenbank enthaelt keine Defender-Importe, der Versionssprung folgt mit `c7c716c`. Keine API-, SQL- oder Persistenzmigration. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | diff --git a/src/components/FindingCard/FindingCard.tsx b/src/components/FindingCard/FindingCard.tsx index 305316b..cd572e1 100644 --- a/src/components/FindingCard/FindingCard.tsx +++ b/src/components/FindingCard/FindingCard.tsx @@ -1,8 +1,8 @@ import { memo } 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,9 +24,8 @@ 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); return ( )} diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index 9f0fbf6..7e46086 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -22,7 +22,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 { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles'; import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation'; @@ -412,6 +412,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown'; const problem = resolvedToolId === 'group-policy' && !policyReadiness ? policyProblemSummary(record) : undefined; const firstRead = resolvedToolId !== 'group-policy' ? findingFirstRead(record, resolvedToolId, linkedSheets, comparisonOutcome) : undefined; + 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 @@ -439,7 +440,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)}} {/* The heading already names the CIS section; each number badge names its section on hover. */} {derivedCategories.map(category => {highlightText(category, search)})} {badges.map(field => { diff --git a/src/features/linked-results/result-message.ts b/src/features/linked-results/result-message.ts index 7f64f94..c95310e 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'; @@ -191,6 +191,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..3a561be 100644 --- a/src/styles/defender-for-iot.css +++ b/src/styles/defender-for-iot.css @@ -1,17 +1,17 @@ -.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(--text); background: var(--surface-app); } +.defender-header { order: 0; } +.defender-source strong { color: var(--heading); 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: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; } +.defender-metrics span { color: var(--muted); 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-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: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-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); } @@ -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: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-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 85908ab..799a56b 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'] as const]"); expect(dashboard).toContain("[['defender', 'Microsoft Defender for IoT'] as const]"); 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 = [{