From efec1f593a143332cee30f77b67aa25ec1ca14d4 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 30 Sep 2026 09:49:38 +0200 Subject: [PATCH] 2.9.1: Management Report mit Heatmap und Ergebnisbalken Die Client-Info-Aktion Management Report erzeugt den faktenbasierten HTML-Bericht aus der gemeinsamen Reportprojektion: erfasste DC, bewertete und abweichende Ergebnisse, Heatmap des Abweichungsanteils und Pass-/Abweichungsbalken je Security Domain. Kein Mixed-Method-Score, keine Scannerlisten, keine LLM-, Internet- oder Risikoableitung. Report B bleibt technisch. Keine Projektions-, API- oder Persistenz- aenderung. Verifiziert: Vollsuite 1645 bestanden, 14 uebersprungen; 7 bestehende Fehler in 6 Dateien identisch auf dem vorherigen Commit. Beide Produktionsbuilds bestanden. Visuelle Browserabnahme offen. Co-Authored-By: Claude Opus 5.5 --- docs/IMPLEMENTATION_PLAN.md | 20 + docs/REPORT_STRATEGY.md | 27 + package-lock.json | 4 +- package.json | 2 +- src/exports/management-overview-report.ts | 590 +++--------------- src/features/dashboard/DashboardPanel.tsx | 2 +- .../dashboard/OperatingSystemInventory.tsx | 4 +- src/styles/ui-tokens.css | 1 + tests/app/app-version.test.ts | 2 +- .../management-overview-report.test.ts | 280 ++------- tests/features/client-info-facts.test.tsx | 5 +- tests/features/client-report-buttons.test.tsx | 6 +- tests/server/assessor-http-server.test.ts | 2 +- 13 files changed, 203 insertions(+), 742 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 2cdab68..d3ba255 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -6,6 +6,26 @@ Referenzerkennung 2026-09-26 (Phase 13D, erster Umfang umgesetzt und verifiziert Status: lebendes Projektdokument +2026-09-29 - Phase 7 / produktiver Management Report mit Heatmap und Ergebnisbalken (2.9.1, umgesetzt und fokussiert verifiziert): Die bestehende Client-Info-Aktion `Management Report` erzeugt fuer den gewaehlten Kunden und Timeslot jetzt den faktenbasierten HTML-Bericht aus der gemeinsamen Reportprojektion. Der alte Mixed-Method-Score, Donut, Domain-Spine, Priority-Finding-/Scannerlisten, Konvergenz, Massnahmen und Source Overview entfallen nur aus Report A; Report B bleibt technisch. Der Bericht zeigt erfasste DC, bewertete und abweichende Ergebnisse, eine Heatmap des Abweichungsanteils und die zusaetzlich vom Nutzer gezeigte Pass-/Abweichungs-Grafik mit Pass Rate und Stueckzahlen je Security Domain. Unbewertete Bereiche erhalten keine Prozentzahl; GPO-Ergebniszeilen und Policy-Vergleiche bleiben getrennte Zaehleinheiten. Keine LLM-, Internet-, Zeitfortschreibungs- oder Business-Risikoableitung. Der separate Dummy-HTML-Entwurf und sein Download-ZIP enthalten dieselbe zweite Grafik; die Produktionszahlen sind dynamisch, der Dummy-Entwurf bleibt ein gespeicherter Beispielstand. `docs/REPORT_STRATEGY.md` haelt die explizite Abloesung des frueheren Report-A-Inhaltsvertrags fest. Entscheidung: Patchversion 2.9.1 fuer die rueckwaertskompatible Export-Darstellung; keine Projektions-, API-, Persistenz-, Import- oder Datenbankaenderung. Verifiziert: 13 fokussierte Renderer-, Client-Info-Klickpfad- und UI/API-Versionsvertragstests, beide Produktionsbuilds, Design- und Local-only-Pruefung sowie `git diff --check`. Risiko/Abnahmegrenze: Die Security-Domain-Zuordnung bleibt die bestehende heuristische Zuordnung; die Grafik ist keine Risiko- oder Schutzgradbewertung. Eine visuelle Browserabnahme des produktiv heruntergeladenen HTML steht aus; der Phasenstatus wird nicht als neu abgeschlossen markiert. Nachpruefung 2026-09-30 vor dem Release-Commit: Vollsuite 1645 bestanden, 14 uebersprungen, 7 Fehler in 6 Dateien; dieselben 7 Fehler treten auf dem vorherigen Commit ohne 2.9.1 identisch auf und sind damit bestehend, nicht durch 2.9.1 verursacht. Client- und Serverproduktionsbuild bestanden. + +2026-09-29 - Phase 7 / faktenbasierte Management-HTML mit Heatmap: Auf ausdrueckliche Nutzerkorrektur zeigt reports/management-dummy-fakten-heatmap-2026-09-29.html nur heute vorhandene Dummy-Reportwerte (ein erfasster DC; 998 bewertete, davon 579 abweichende Ergebnisse; neun Security-Domain-Zuordnungen einschliesslich Nicht zugeordnet). Die Heatmap kodiert ausschliesslich den Anteil abweichender bewerteter Ergebniszeilen je Bereich, nicht Risiko oder Schwere; GPO-Ergebniszeilen und gesonderte Policy-Vergleiche sind sichtbar als unterschiedliche Zaehleinheiten bezeichnet. Kein Mixed-Method-Score, keine scannerbezogene Rangliste, keine erfundenen Szenarien, Business-Auswirkungen oder Zeitfortschreibung. Statische Pruefung: neun Tilewerte und Farbstufen korrekt, Summen 579/998, lokale Assets vorhanden, keine Skripte. Dies ist ein separater HTML-Darstellungsentwurf aus bereits gespeicherten Dummy-Exportzahlen, keine produktive Generatoraenderung und kein frischer Datenbankabzug. Produktversion unveraendert; visuelle Browserabnahme wegen lokaler URL-Richtlinie offen. + +2026-09-29 - Phase 7 / manuell redigierter Management-Brief zum freigegebenen Dummy: Nach Nutzerentscheidung, vorhandene Dummy-Befunde fachlich zu verdichten, wurde reports/management-dummy-managementbrief-2026-09-29.html als neuer einseitiger HTML-Entwurf erstellt. Der Brief nutzt das zuletzt gewuenschte neutrale ASSESSOR-Layout, trennt je Risikobereich Beobachtung, moegliche Betriebswirkung und offenen Nachweis und verzichtet auf Mischscore, Domain-Balken, Toolnamen, Findinglisten, erfundene Fristen und Fortschrittsbehauptungen. Die Aussagen zu geschuetzten AD-Rechten, Admin-Aktivitaet, Zertifikatsdiensten, einem erfassten DC, zuletzt gemeldetem Backupdatum und Cloud-Zugriffsschutz beruhen auf den zuvor gesicherten Dummy-Befunden; kritische Dienste, OT-Abhaengigkeiten, tatsaechliche Ausnutzung und Wiederherstellbarkeit bleiben offen. Dies ist manuelle Berichtserstellung und belegt noch keine entsprechende automatische ASSESSOR-Funktion. Statische HTML-/Daten- und lokale Assetpruefung bestanden (ein Blatt, drei getrennte Risikobereiche, keine Scanner-/Score-/Zeitvorgaben); Browser-Sichtpruefung bleibt wegen der lokalen URL-Sicherheitsrichtlinie offen. Produktive Report-Erzeugung und Produktversion unveraendert. + +2026-09-29 - Phase 7 / Management-Report-Machbarkeitsansicht: reports/management-dummy-live-moeglich-2026-09-29.html zeigt im Look des zuletzt erarbeiteten Entwurfs ausschliesslich bereits vorhandene Report-Projektionsdaten aus dem zuvor erzeugten produktiven Dummy-Export (Projekt/Domaene, ein erfasster DC, 998 bewertete und 579 abweichende technische Ergebnisse, Reviewstatus nur soweit vorhanden). Es gibt keinen Mixed-Method-Score und keine manuell erfundenen Risikoszenarien, Business-Auswirkungen, Restaurierungsannahmen oder aktuellen Zustandsbehauptungen. Fehlende Business-/OT-Abhaengigkeiten und fehlende Szenarioregeln sind als Aussagegrenze sichtbar. Dies ist ein separater statischer HTML-Darstellungsentwurf, keine neue produktive Generatorfunktion und kein frischer Datenbankabzug. Statische Pruefung: lokale Assets und HTML-Struktur gueltig, Werte mit dem bestehenden Dummy-Export abgeglichen, keine Toolnamen oder Skripte. Produktive Report-Erzeugung und Produktversion bleiben unveraendert; visuelle und fachliche Abnahme offen. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf, Cloud-Pruefstatus aus Managementseite entfernt: Die Balkengrafik zur Auswertbarkeit importierter Cloud-Pruefungen lieferte keine eigenstaendige betriebliche Aussage und wurde aus Seite 1 der separaten Revision entfernt. Die unvollstaendige Pruefbasis bleibt einmal als Aussagegrenze direkt beim Cloud-Risiko auf Seite 2 genannt. Der Bericht behauptet aus der Testanzahl weder Schutzgrad noch Fortschritt. Produktive Report-Erzeugung und Produktversion unveraendert; visuelle Abnahme weiterhin offen. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf, Zeitbezug und Pruefstatus korrigiert: Die fruehere Grafik rechnete das zuletzt gemeldete Backupdatum vom 27.05.2026 bis zum Berichtsdatum fort und suggerierte damit ohne Nachweis eine 125 Tage andauernde fehlende Sicherung. Die korrigierte, separat gespeicherte Fassung reports/management-dummy-2026-09-29-revision.html zeigt nur die Ist-Angaben der Erhebung (ein erfasster DC, zuletzt gemeldetes Backupdatum). Die zweite Grafik heisst nun Auswertbarkeit der Cloud-Pruefungen und trennt 147 bewertete, 169 nicht ausgefuehrte und zwei weitere unbewertete Ergebnisse; sie ist keine Schutz- oder Umgebungsabdeckungsquote. Der zuvor gelieferte Berichtspfad wurde zwischenzeitlich anderweitig geaendert und deshalb nicht ueberschrieben. Keine Aenderung an produktiver Report-Erzeugung oder Produktversion; fachliche und visuelle Abnahme bleiben offen. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf 0.3, Nutzerkorrektur: Der Bericht ist ein ASSESSOR-Bericht, der die Scanner nur als Datenquellen nutzt, und eine Information fuer das Management, keine Anleitung. `reports/management-dummy-2026-09-29.html` nennt daher keine Werkzeuge und keine Scanner-Finding-Titel, enthaelt keine Etappen, Zeithorizonte, Owner oder Empfehlungslisten und verzichtet auf Kommentar- und Rechtfertigungssaetze. Aufbau: Kopfdaten, Zusammenfassung mit Kennzahlen, Gesamtbewertung mit drei statischen SVG-Grafiken (Ergebnis der 998 auswertbaren Pruefungen, Pass Rate und Abweichungen je Security Domain), sieben thematische Feststellungen mit Sachverhalt und Einordnung, Bewertungsgrundlage mit der verbindlichen GPO-/Mixed-Method-Formulierung. Die Optik der Fassung 0.2 ist nach Nutzerangabe die visuelle Zielvorgabe und wurde beibehalten. Zahlen stammen aus dem produktiven Dummy-Management-Export vom selben Tag. Browserpruefung: 3 Grafiken, 7 Feststellungen, kein horizontaler Ueberlauf, keine externen Ressourcen. Produktive Report-Erzeugung, `docs/REPORT_STRATEGY.md` und Produktversion unveraendert; fachliche Abnahme offen. Ersetzt die Fassungen darunter. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf, fachliche Nutzerkorrektur: Der separate Dummy-HTML-Bericht ist jetzt eine zweiseitige, sachliche Managementeinordnung statt einer Scannerliste mit Massnahmen-Roadmap. Toolnamen, Einzel-Finding-IDs, angenommene Owner, Aufwaende und fixe Fristen wurden aus dem Bericht entfernt. Die zwei Grafiken zeigen nur belegte Groessen: 125 Tage seit der zuletzt erfassten AD-Sicherung (27.05. bis 29.09.2026) und die Cloud-Pruefabdeckung mit 147 bewerteten von 318 Pruefungen; 169 von 318 wurden nicht ausgefuehrt. Risiken werden als moegliche betriebliche Folgen beschrieben; Kompromittierung, Restore-Faehigkeit sowie konkrete Dienste-/OT-Abhaengigkeiten bleiben ausdruecklich offen. Statische Pruefung: zwei Seiten, zwei Grafiken, lokale Assets vorhanden, Zahlen und Links konsistent. Eine visuelle Browserpruefung durch den Agenten war wegen der Browser-URL-Sicherheitsrichtlinie fuer lokale Dateien nicht moeglich. Die produktive Report-Erzeugung und Produktversion bleiben unveraendert; fachliche und visuelle Abnahme sind offen. Diese Korrektur ersetzt die Inhaltseinteilung des frueheren separaten Entwurfs vom selben Tag, nicht die produktive Reportstrategie. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf, Lesbarkeitskorrektur: Nach dem ersten Oeffnen zeigte der Quelltext lange Base64-Zeilen fuer Schrift und Logo. Beide Assets liegen jetzt als lokale Dateien unter `reports/assets/`, waehrend die HTML-Datei relative Pfade verwendet. Der Bericht bleibt ohne Netzwerkressourcen und kann als Paket aus HTML und Assets bearbeitet und weitergegeben werden. Keine Aenderung an der produktiven Report-Erzeugung oder Produktversion; fachliche Abnahme bleibt offen. + +2026-09-29 - Phase 7 / Management-Report-Arbeitsentwurf fuer Dummy (eigenstaendige HTML-Datei, fachliche und visuelle Abnahme offen): `reports/management-dummy-2026-09-29.html` wurde als selbsttragende, statische Entscheidungsvorlage aus der gespeicherten Report-Projektion des freigegebenen Dummy-Projekts erstellt. Entscheidung fuer diesen Entwurf: Seite 1 nennt die ungeklärten AdminSDHolder-/Admin-Aktivitaets-Findings und drei begruendete Risikoszenarien ohne Mischscore; Seite 2 traegt Evidenz-IDs, angenommene Owner/Fristen/Aufwaende, Kontrollen und Restrisiken; der Anhang trennt Scanner-Findings und GPO-Zaehleinheiten. Die produktive Management-Overview-Erzeugung und `docs/REPORT_STRATEGY.md` bleiben unveraendert; dieser Entwurf ist keine Freigabe eines neuen Exportvertrags. Verifiziert: 8 HTML-Abschnitte, alle internen Evidenzlinks aufloesbar, keine externen Ressourcen und keine Registry-Pfade im sichtbaren Text; Browser-Vorschau geprueft. Risiken/offene Abnahme: Kritische Business Services und OT-Abhaengigkeiten, Change-Erklaerung, Gegenkontrollen, formelle Risikoakzeptanz und Beschlussdaten sind im Import nicht belegt; das Backupdatum ist 27.05.2026 (am Entwurfsdatum 125 Tage alt), waehrend die 55 Tage nur der alte PingCastle-Scantext sind. Kein Produktversionsschritt, weil nur ein separat bearbeitbarer Berichtsentwurf ohne Aenderung des Anwendungs-Vertrags entstanden ist. + 2026-09-29 - Client-Info-Umgebungskacheln (Phase 7, Darstellung umgesetzt; visuelle Abnahme offen): Die sechs vorhandenen Kacheln bleiben bei Vollbildbreite in einer Rasterzeile, haben gleiche quadratische Geometrie und laufen bei schmaleren Fenstern innerhalb des Rasters horizontal statt nach unten umzubrechen. Jede Kurzansicht zeigt hoechstens vier quellengestuetzte Fakten, in schmalen Quadraten zwei; alle weiteren Werte und Quelltabellen bleiben ueber den jeweiligen Results-Button zugaenglich. Die Betriebs- und Scandaten wurden nicht veraendert. Acht fokussierte Tests einschliesslich synthetischem Results-Klickpfad, die Designpruefung, der Produktversionsvertrag und beide Produktionsbuilds bestanden. Risiko/Abnahmegrenze: Der lokale Browser zeigte sechs gleich grosse Quadrate in einer Zeile; bei 260px Breite verdeckten lange Fakten vor der Nachkorrektur teilweise die zweite Faktenzeile. Die daraufhin ergaenzte Containerregel ist gebaut und getestet, aber ein erneuter sichtbarer Dark-/Light-Vergleich bei normalem Vollbildzoom ist wegen stockender Client-Navigation noch ausstehend. Die bestehende Version 2.9.0 wird wegen dieser offenen UI-Abnahme und weiterer paralleler, noch nicht gemeinsam freigegebener Arbeitsstaende nicht fuer diese einzelne Aenderung erhoeht. Der Phasenstatus bleibt unveraendert. 2026-09-28 - Phase 13 / Result DB ohne Umgebungs-Fingerabdruecke (2.9.0, umgesetzt und verifiziert): Eine Threat-Model-Pruefung des JSONL-Exports zeigte zwei Probleme. Erstens ein Vertragsverstoss: Die Offenlegungsprojektion kopierte alle Felder, sodass `reviewStatus`, `reviewDisposition` (inklusive akzeptierter Risiken) und `reviewPriority` in Result DB API und JSONL gelangten. Zweitens erlaubten exakte Anzahlen, Zeitangaben, GPO-Istwerte mit Principals, Umgebungsbeobachtungen (DC-Anzahl, Funktionsebenen, Scores) und konkrete Baseline-Releases, die Umgebung wiederzuerkennen und den Angriffsaufwand zu kalibrieren. Neue verbindliche Nutzeranforderung: Die Result DB muss datenschutzkonform sein und darf keine Angriffsanleitung bieten; Exporte koennen an lokale und externe LLMs sowie Dritte gehen. Diese Anforderung ersetzt ausdruecklich die Entscheidung vom 2026-09-25, Istwert, Scannerwerte und Baseline-Release vollstaendig offenzulegen (AGENTS.md aktualisiert). Umsetzung: Disclosure Policy 3.0.0 mit kompilergepruefter Feldklassifikation `RESULT_DB_FIELD_DISCLOSURE`; Review-Spalte und Review-Facetten aus der Result DB entfernt; GPO nur noch mit Setting-ID, Sollwert, Relation und Baseline-Familie, zusammengefassten identischen Baseline-Erwartungen und ohne Quellenanzahl; Anzahlen, Datum, Zeit und Dauer in Scannerprosa ausser CIS-CAT als `[count]`/`[date]`/`[time]`/`[duration]` bei erhaltenen oeffentlichen Kennungen; Zusatzbeobachtungs-Policy 2.0.0 nur mit binaeren Konfigurationszustaenden; JSONL-Schema 3.0.0 mit `generatedOn` (nur Datum), datiertem Dateinamen und Kennzeichnung `confidential-security-posture`. Work und interne Analyse behalten alle Originale; keine Import-, Schema-, Persistenz- oder Bestandsmutation und kein Reimport. Risiko/Grenze: Oeffentliche Zahlenschwellen in Nicht-CIS-Empfehlungstexten (etwa Passwortlaengen) werden ebenfalls zu `[count]`, weil statischer und umgebungsbezogener Scannertext ohne Katalogbeleg nicht sicher unterscheidbar ist; das LLM verliert zudem Istwert und Mengen. Die inhaltsfreie Live-Pruefung gegen den laufenden Backend-Stand 2.9.0 mit der kanonischen PostgreSQL (nur Zaehler und Wahrheitswerte, keine Inhalte) fand drei weitere Punkte, die behoben sind: GPO-Titel aus Registry-Pfaden (`Software\Policies`) wurden seit Policy 2.5.0 faelschlich als `DOMAIN\Konto` in Principal-Platzhalter verwandelt (10.367 Zeilen; Titel behalten jetzt wie die Regel-ID ihre technische Identitaet), dokumentierte Microsoft-Principals im Sollwert nutzen jetzt die zentrale Liste `src/shared/windows-codes.ts` samt `NT SERVICE`/`NT VIRTUAL MACHINE`/`Window Manager`/`Font Driver Host`, und kompakte Dauerangaben (`24h`, `30d`) werden ersetzt. Live-Ergebnis: 14.726 Result-DB-Zeilen ohne Review-, Istwert-, Quellenanzahl- oder Identitaetsfelder, keine freistehende Ziffer in Nicht-CIS-Scannerprosa, alle GPO-Baselines nur als Familie, keine Istwerte in Varianten, Umgebungszeilen nur boolesch, keine Review-Facette; der Einzelabruf per ID ueber das PostgreSQL-Repository (28 Zeilen, darunter 8 mit internem Reviewstatus) gibt keine zurueckgehaltenen Felder aus; die Oberflaeche zeigt 9 Spalten ohne Review-Spalte und -Filter und im GPO-Detail nur Sollwert, Relation und Baseline-Familie. Grenze: 6 Sollwerte enthalten je einen nicht gelisteten Principal (SID-Muster `S-1-5-99-*`), der fail-closed als Platzhalter bleibt. Der PostgreSQL-Integrationstest wurde nicht ausgefuehrt, weil er Migrationen und Loeschungen ausfuehrt und laut `docs/POSTGRESQL.md` nie auf die produktive Datenbank zeigen darf, waehrend AGENTS.md eine zweite ASSESSOR-Datenbank verbietet. Tests: synthetische Fingerabdruck- und Ziffernwaechter-Tests fuer Projektion und JSONL, Baseline-Familie, Zusammenfassung der Varianten, Zusatzbeobachtungen, lokales Repository, angepasste PostgreSQL-Integrationserwartung (nicht gegen die kanonische Datenbank ausgefuehrt) und die Nutzerinteraktion `Export JSONL` in der Result DB. Fokussierte Tests, Typpruefung, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild bestanden; die volle Suite zeigt nur die 7 bereits auf dem unveraenderten Stand d8f131d fehlschlagenden Tests in Client-Info-/GPO-/Timeslot-Dateien. Nachtrag 2026-09-28 (Disclosure Policy 3.1.0, gleicher unveroeffentlichter Stand 2.9.0): Die Nutzerpruefung des Dummy-Exports (Policy 3.0.0; Dummy ausdruecklich zur Inhaltspruefung freigegeben) bestaetigte die Schliessung der Lecks und fand Ueberschwaerzung. Behoben: `[count].` statt Schrittnummern, Hersteller-Schwellenwerte im Empfehlungsfeld (Titel und Beschreibung bleiben voll geschwaerzt), CIS-Nummern und `Rule`-Verweise als `[ip]` (IPv4 nur noch alleinstehend, nie Teil laengerer Punktfolgen oder dokumentierter Bereichsgrenzen) sowie oeffentliche Richtlinien-/Registry-Pfade, umgebrochene Pfadzeilen und dokumentierte Windows-Autoritaeten als `[account]` (Dummy vorher 1.443 laut Nutzerauswertung, jetzt 1); der Handling-Hinweis nennt die erhaltenen oeffentlichen Baseline-Erwartungen. Live ueber alle Clients (nur Zaehler): 0 `[ip]`, 1 `[account]` (Named Pipe `SQL\QUERY`, bewusst fail-closed), 0 zerstoerte Listenmarker, keine freistehende Ziffer in Nicht-CIS-Titeln/-Beschreibungen. Offen: oeffentliche Domains, URLs und Graph-Berechtigungsnamen (`Directory.ReadWrite.All`) werden weiterhin zu `[domain]`/`[url]`; die Versionsfelder bleiben als Verfahrens- und Kompatibilitaetsangabe erhalten. Nachtrag 2 (Disclosure Policy 3.2.0, weiterhin unveroeffentlichter Stand 2.9.0): Die zweite Nutzerpruefung bestaetigte, dass meine vorherigen Dummy-Zaehler ungefiltert waren (4.760 statt 579 Abweichungszeilen) und die Listenmarker-Korrektur nur Zeilenanfaenge erfasste. Behoben im Exportumfang `deviation:true`: `[count].` in Inline-Aufzaehlungen 172 -> 1 (verbleibend eine einzelne Nummer ohne Folge), `[url]` 263 -> 2 (Platzhalter-Host `IP`), `[domain]` 58 -> 1 (Drittanbieter-Domain in einem Codebeispiel), `[account]` 0; ausserdem Zielwerte `set to N` in Empfehlungen, `adminCount=1`, Abschnittsnummern `CISA N.`, `Microsoft 365` und die Leerzeichen-Bereinigung vor `.onmicrosoft.com`. Die inhaltsfreie Pruefung ueber alle Clients fand 15 verbliebene `onmicrosoft.com`-Nennungen, alle generischer Maester-Text MT.1074 (`the .onmicrosoft.com domain`), kein Tenant-Name; 0 E-Mail-Adressen, 0 `[ip]`, keine Review- oder Istwertfelder. Nachtrag 3: Eine selbst eingefuehrte Luecke ist behoben: Links wurden erst nach dem Einsetzen von `[id]` bewertet, sodass ein Portal-Link an `[id]` abbrach und der Rest (etwa `/resourceGroups/`) im Klartext blieb. Links werden jetzt zuerst und als Ganzes bewertet; GUIDs, Platzhalter und Azure-Ressourcenpfade machen einen Link nicht oeffentlich. Synthetischer Regressionstest vorhanden. Nachtrag 4 (Phase 11/13, Migration 024 nach verifiziertem Backup `before-result-db-materialization-20260928.dump`, 388 TOC-Eintraege, SHA-256 im Manifest): Auf verbindliche Nutzeranforderung wird die Result DB beim Erzeugen geprueft und gespeichert (WYSIWYG fuer UI, API, Einzelabruf und JSONL) und traegt pro Zeile ein inhaltsfreies Protokoll; die Ansicht zeigt den Pruefstand und `Open original in Work`. Ein Identitaetswaechter pro Client (1.3.0) entfernt die eigenen Namen des Clients; drei Fehlalarmquellen (Asset-Kategorien, `*.name`-Umgebungsfelder, Registry-Pfade aus GPO-Werten) wurden an Dummy und ueber inhaltsfreie Zaehler gefunden und behoben. Live: 14.726 Zeilen, 0 Zeilen mit gesperrten Feldern, Aufbau rund 11 s nach Start. Ebenfalls auf Nutzerentscheidung: Maester zeigt die Ergebnisaussage (`ResultDetail` bzw. `Result` neben `Status`) wieder wortgetreu als `Problem`, live an Dummy bestaetigt. Der PostgreSQL-Integrationstest wurde nicht ausgefuehrt (keine erlaubte Testdatenbank). Die volle Suite zeigt nur die 7 bereits auf d8f131d fehlschlagenden Tests. Nachtrag 5 (2026-09-29, Disclosure Policy 3.3.1, weiterhin unveroeffentlichter Stand 2.9.0): Eine externe Bewertung des Dummy-Exports (Policy 3.2.0) fand drei Punkte, die am Code bestaetigt und behoben sind. Erstens verriet die Protokollliste `withheldFields`, bei welchen Zeilen der Client Reviewstatus, -prioritaet, -verantwortliche oder Faelligkeit gepflegt hatte; die Review-Felder werden jetzt zurueckgehalten, ohne im Protokoll zu erscheinen. Zweitens wurde `score` je Finding offengelegt; Purple-Knight-Indikatorscores und berechnete PingCastle-Punkte skalieren mit den betroffenen Objekten und werden jetzt zurueckgehalten. Drittens Ueberschwaerzung: `Limited` gilt nicht mehr als Rechtsform (Rechtsformen nur noch in registrierter Schreibweise), Versionen direkt am Namen (`Tls1.2`, `v1.0`), `Windows 95`, `2012-R2` sowie Verweise `step N`, `Option N` und `Page N` bleiben, dokumentierte Microsoft-GUIDs aus dem oeffentlichen Katalog (ASR-Regeln, erstanbieterseitige Anwendungs-IDs, eingebaute Rollenvorlagen, gegen Microsoft Learn geprueft) bleiben, jede andere GUID bleibt `[id]`. Die gemeldete ID-Kollision `18.10.42.11.1.1` war kein Parserfehler: Import und Original tragen `18.10.42.11.1.1.1` und `.2`, aber die Offenlegung liess nur sechs Ebenen durch; CIS-Nummern behalten jetzt bis zu acht Ebenen. Bewusst unveraendert, weil verbindlich: Zahlen in Titeln und Beschreibungen bleiben geschwaerzt (etwa CISA-Verhaeltnis, MachineAccountQuota-Standard), der Trace bleibt Teil jeder Zeile und des JSONL, GPO-Vergleiche werden weiter einzeln gezaehlt. Live nach automatischem Neuaufbau (Dummy freigegeben): 579 Dummy-Abweichungszeilen mit 0 `[organization]`, 0 `[id]`, 0 doppelten CIS-IDs; ueber alle Clients 14.726 Zeilen, 0 mit `score`, 0 mit Review-Feldnamen. Synthetische Regressionstests fuer Projektion, Protokoll und die Nutzerinteraktion Zeilendetail/`Export JSONL` mit gespeicherten Zeilen; keine Schema-, Import- oder Originaldatenaenderung, kein Reimport. Nachtrag 6 (2026-09-29, Disclosure Policy 3.4.0, gleicher Stand 2.9.0): Auf Nutzerentscheidung behaelt das Empfehlungsfeld zusaetzlich dokumentierte Herstellerwerte (`set to N` innerhalb eines Satzes ohne weitere Zahl, `value is N`, `the number N`, `assign N`, `N as the Value`, `N for mode`, Attributfilter wie `admincount=1`, `zero (0)`, `N+`, `N methods` und `only N will be listed`); Titel und Beschreibungen bleiben voll geschwaerzt, beobachtete Anzahlen im Empfehlungsfeld bleiben `[count]`. Dummy: `[count]` im Empfehlungsfeld 15 -> 0 (18 vor der `Option N`-Korrektur) (Titel 17, Beschreibung 9 unveraendert). Inhaltsfreie Pruefung ueber alle Clients: 16 Regeln mit neu erhaltenen Werten, jede mit genau einem Wertesatz ueber alle Clients, in denen sie vorkommt; 5 Regeln kommen nur bei einem Nicht-Dummy-Client vor, alle mit oeffentlicher Regel-ID und Anweisungsform (`set ... to N`, Attributfilter), nicht inhaltlich geprueft. Die volle Suite zeigt neben den 7 bekannten Fehlschlaegen 3 weitere in `client-info-facts.test.tsx`, die aus parallelen, nicht von dieser Aenderung stammenden Client-Info-Anpassungen (`DashboardPanel.tsx`, `OperatingSystemInventory.tsx`, `ui-tokens.css`) folgen. diff --git a/docs/REPORT_STRATEGY.md b/docs/REPORT_STRATEGY.md index 30e34a5..3c939a4 100644 --- a/docs/REPORT_STRATEGY.md +++ b/docs/REPORT_STRATEGY.md @@ -98,6 +98,33 @@ consumes. ## Report A — Management Overview +**Superseding user decision 2026-09-29 (Phase 7):** The Management Report is +generated offline and without an LLM from the current, selected client's +existing report projection. It is a factual overview, not a scanner finding +list, mixed-method score or speculative risk assessment. The earlier Report A +content contract below (overall-score donut, domain spine, priority findings, +remediation status and source overview) is retained as historical context but +does not govern the current Management export. + +Current Report A shows project name, report date, the captured domain and DC +count when present, counts of assessed and deviating results, a heatmap of +deviations divided by assessed results for each catalog Security Domain, and a +second stacked chart of passed and deviating results with the Pass Rate and +underlying counts. A domain with no assessed results has no percentage or heat +level. The colors and percentages describe result frequency only: they do not +express severity, business impact, compromise, protection or progress. + +The GPO domain chart counts assigned result rows. When the separate policy +evaluation exists, its failing comparisons and affected settings are stated +separately and never equated to result rows. The export names its statement +limits: source observations may come from different times; affected business +services, exploitation and current remediation state are not derivable from +these facts. It does not extrapolate backup age or add invented risk scenarios, +owners, measures or deadlines. Technical results remain in Report B. The +document is standalone HTML with inline styles and no external resources. + +### Historical Report A design (superseded 2026-09-29) + Implemented in `src/exports/management-overview-report.ts` (`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by `tests/exports/management-overview-report.test.ts`. diff --git a/package-lock.json b/package-lock.json index 0c2ff74..7cf9693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assessor", - "version": "2.9.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assessor", - "version": "2.9.0", + "version": "2.9.1", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index 7ccec13..6fbd3af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.9.0", + "version": "2.9.1", "private": true, "type": "module", "scripts": { diff --git a/src/exports/management-overview-report.ts b/src/exports/management-overview-report.ts index 24e2e2d..fba2733 100644 --- a/src/exports/management-overview-report.ts +++ b/src/exports/management-overview-report.ts @@ -1,520 +1,102 @@ -import type { - ReportDomainPriority, - ReportDomainSummary, - ReportFindingRef, - ReportGpoBlock, - ReportPriorityBasis, - ReportProjection, - ReportScoreSource -} from '../domain/report-projection'; +import type { ReportDomainSummary, ReportProjection } from '../domain/report-projection'; +import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release'; import { escapeHtml } from '../shared/html'; -/** - * Artifact A of docs/REPORT_STRATEGY.md: the Management Overview. - * - * A frozen snapshot for the customer's decision makers. It answers how the - * environment stands, what is most urgent, what the statement rests on and what - * is not known — and deliberately carries no registry keys, SIDs, evidence paths - * or parser versions, so the file can be forwarded as it is. - * - * German carries the prose; established English technical terminology stays - * English. The file is self-contained: every style is inline, nothing is - * fetched when it is opened. - */ -export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.1.0'; - +/** Offline management report: a factual view of the shared projection. */ +export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.0.0'; const DASH = '—'; +const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH; -/** Unknown stays unknown. An absent value never becomes a zero. */ -function value(input: string | number | undefined | null): string { - if (input === undefined || input === null) return DASH; - if (typeof input === 'number' && !Number.isFinite(input)) return DASH; - const text = String(input).trim(); - return text === '' ? DASH : escapeHtml(text); -} - -function integer(input: number | undefined): string { - return typeof input === 'number' && Number.isFinite(input) - ? escapeHtml(input.toLocaleString('de-DE')) - : DASH; -} - -function percent(ratio: number | undefined, digits = 0): string { - return typeof ratio === 'number' && Number.isFinite(ratio) - ? escapeHtml((ratio * 100).toLocaleString('de-DE', { - minimumFractionDigits: digits, maximumFractionDigits: digits - })) - : DASH; -} - -function germanDateTime(iso: string): string { +function date(iso: string): string { const parsed = Date.parse(iso); - if (!Number.isFinite(parsed)) return value(iso); - return escapeHtml(new Date(parsed).toLocaleString('de-DE', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' - })); -} - -function germanDate(iso: string | undefined): string { - if (!iso) return DASH; - const parsed = Date.parse(iso); - if (!Number.isFinite(parsed)) return value(iso); - return escapeHtml(new Date(parsed).toLocaleDateString('de-DE', { - year: 'numeric', month: '2-digit', day: '2-digit' - })); -} - -/** - * Binding wording from docs/SCORING_MODEL.md. A method is named, never implied, - * and the GPO contribution is never called Baseline-Compliance. - */ -const SCORE_METHOD_LABEL: Record = { - native: 'Nativer Score des Werkzeugs', - 'inverted-native': 'Nativer Risk, invertiert als 100 − Risk', - 'pass-rate': 'Pass Rate der bewerteten Checks', - unavailable: 'Kein verwertbarer Score' -}; - -/** Names why a source is ordered the way it is, or why it is not ordered. */ -function basisLabel(basis: ReportPriorityBasis): string { - if (basis.kind === 'severity') return 'Sortiert nach nativer Severity'; - if (basis.kind === 'numeric') { - const direction = basis.direction === 'higher-is-worse' ? 'höher ist kritischer' : 'niedriger ist kritischer'; - return `Sortiert nach ${escapeHtml(basis.property)} (${escapeHtml(basis.scale)}, ${direction})`; - } - if (basis.kind === 'equal-weight') return `Ohne Rangfolge: ${escapeHtml(basis.reason)}`; - return 'Ohne Rangfolge: Die Quelle deklariert keinen belastbaren Maßstab.'; -} - -const SEVERITY_LABEL: Record = { - critical: 'Critical', high: 'High', medium: 'Medium', - low: 'Low', info: 'Info', unknown: 'Unknown' -}; - -/** - * Shared donut geometry: full 360 degrees, continuous neutral track, value arc - * clockwise from twelve o'clock, flat caps. The number sits on the exact centre - * and its unit follows to the right without displacing it. - */ -function donut(score: number | undefined, caption: string): string { - const radius = 54; - const circumference = 2 * Math.PI * radius; - const ratio = typeof score === 'number' && Number.isFinite(score) - ? Math.min(Math.max(score, 0), 100) / 100 - : 0; - const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`; - const display = typeof score === 'number' && Number.isFinite(score) - ? score > 0 && score < 0.5 ? '<1' : Math.round(score).toLocaleString('de-DE') + return Number.isFinite(parsed) + ? new Date(parsed).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : DASH; - const hasScore = display !== DASH; - return `
- - - ${hasScore ? `${ratio > 0 && ratio < 0.02 - ? `` - : ''}` : ''} - -
- ${escapeHtml(display)}${hasScore ? '/100' : ''} - ${escapeHtml(caption)} -
-
`; } -/** Domain pass rates as a connected spine, not a conventional table. */ -function domainSpine(domains: ReportDomainSummary[]): string { - if (!domains.length) return '

Keine Security Domain wurde bewertet.

'; - return `
    ${domains.map(domain => { - const known = typeof domain.passRate === 'number'; - const width = known ? Math.round(domain.passRate! * 100) : 0; - return `
  1. - - ${escapeHtml(domain.domainLabel)} - - ${known ? `${percent(domain.passRate)} %` : DASH} - ${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet -
  2. `; - }).join('')}
`; +function tile(item: ReportDomainSummary): string { + const assessed = item.assessed > 0 && Number.isFinite(item.assessed) && Number.isFinite(item.deviations); + const share = assessed ? Math.round(100 * item.deviations / item.assessed) : undefined; + const level = share === undefined ? 'unknown' : `h${share < 25 ? 0 : share < 50 ? 1 : share < 75 ? 2 : 3}`; + return `
  • ${escapeHtml(item.domainLabel)} + ${share === undefined ? DASH : `${count(share)} %`} + ${assessed ? `${count(item.deviations)} von ${count(item.assessed)} bewertet` : 'Keine bewerteten Ergebnisse'}
  • `; } -function findingRow(finding: ReportFindingRef, ranked: boolean): string { - const severity = finding.severity; - const showSeverity = severity !== 'unknown'; - return `
  • - - ${escapeHtml(finding.title)} - - ${showSeverity ? `${escapeHtml(SEVERITY_LABEL[severity])}` : ''} - ${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric' - ? `${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}` : ''} - -
  • `; -} - -function prioritySection(priorities: ReportDomainPriority[]): string { - if (!priorities.length) return '

    Es wurde keine Deviation festgestellt.

    '; - return priorities.map(domain => `
    -
    -

    ${escapeHtml(domain.domainLabel)}

    - ${integer(domain.deviations)} Deviations -
    - ${domain.sources.map(source => `
    -
    - ${escapeHtml(source.toolId)} - ${basisLabel(source.basis)} -
    -
      ${source.shown.map(item => findingRow(item, source.ranked)).join('')}
    - ${source.shown.length < source.total - ? `

    ${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.

    ` - : ''} -
    `).join('')} -
    `).join(''); -} - -/** - * GPO deviations carry no native criticality, so they form their own - * equal-weight block instead of being ranked against severity-bearing sources. - */ -function gpoSection(gpo: ReportGpoBlock): string { - const rows: Array<[string, number]> = [ - ['Fail', gpo.counts.Fail], - ['Review required', gpo.counts['Review required']], - ['Pass', gpo.counts.Pass], - ['Not configured', gpo.counts['Not configured']], - ['No baseline rule', gpo.counts['No baseline rule']], - ['Not comparable', gpo.counts['Not comparable']] - ]; - return `
    -
    -

    GPO & Baseline

    - ${integer(gpo.counts.Fail)} Deviations -
    -

    ${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.

    -
    ${rows.map(([label, count]) => `
    -
    ${escapeHtml(label)}
    ${integer(count)}
    -
    `).join('')}
    -

    - Betroffene Policy Settings: ${integer(gpo.affectedSettings)} · - betroffene GPO Backups: ${integer(gpo.affectedBackups)} · - Conflicts: ${integer(gpo.conflicts)} · - Duplicates: ${integer(gpo.duplicates)} -

    -

    - Not configured, No baseline rule und Not comparable - sind score-neutral und begründen keine Maßnahme. - ${gpo.scopeLimited - ? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz und trifft keine Aussage über die gesamte Domäne.' - : ''} -

    -
    `; +function comparisonRow(item: ReportDomainSummary): string { + const assessed = Number.isFinite(item.assessed) && item.assessed > 0; + const passed = assessed && Number.isFinite(item.passed) ? Math.max(0, Math.min(item.passed, item.assessed)) : 0; + const deviations = assessed && Number.isFinite(item.deviations) ? Math.max(0, Math.min(item.deviations, item.assessed - passed)) : 0; + const other = assessed ? Math.max(0, item.assessed - passed - deviations) : 0; + const passPercent = assessed ? Math.round(100 * passed / item.assessed) : undefined; + return `
  • ${escapeHtml(item.domainLabel)} + + + + ${other ? `` : ''} + + ${passPercent === undefined ? DASH : `${count(passPercent)} %`} · ${assessed ? `${count(item.deviations)} von ${count(item.assessed)}` : DASH}
  • `; } export function buildManagementOverviewHtml(projection: ReportProjection): string { - const facts = projection.environment.length - ? projection.environment - : [{ key: 'Umgebung', value: undefined }]; - - const remediation = projection.remediation; - const coverage = projection.coverage; - - const head = `
    -

    ASSESSOR · Management Overview

    -

    ${escapeHtml(projection.project.name)}

    -
    - ${facts.map(fact => `
    -
    ${escapeHtml(fact.key)}
    ${value(fact.value)}
    -
    `).join('')} -
    Assessment
    ${value(projection.assessmentId)}
    -
    Berichtsdatum
    ${germanDateTime(projection.generatedAt)}
    -
    -
    `; - - const posture = `
    -

    Overall Posture

    -
    -
    - ${donut(projection.overallScore, 'Overall Score')} -
    -
    -

    Security Domains

    - ${domainSpine(projection.domains)} -
    -
    -

    - Der Overall Score ist ein Mixed-Method-Index über die unten genannten Quellen. - Der GPO-Anteil bezeichnet die Konformität der konfigurierten, vergleichbaren - Einstellungen im Scope — er ist keine Aussage über eine vollständig - ausgerollte Microsoft Baseline. Security-Domain-Werte sind Pass Rates und keine - Zerlegung des Index. -

    -
    `; - - const priorities = `
    -

    Priority Findings

    -

    - Gruppiert nach Security Domain. Innerhalb einer Domain wird je Quelle nach deren - eigenem, deklariertem Maßstab geordnet; eine quellenübergreifende Rangfolge - gibt es nicht, weil die Maßstäbe nicht vergleichbar sind. -

    -
    - ${prioritySection(projection.priorities)} - ${projection.gpo ? gpoSection(projection.gpo) : ''} -
    -
    `; - - const convergence = `
    -

    Konvergenz der Quellen

    -

    Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.

    -
    ${projection.convergence.filter(item => item.sourceCount > 0).map(item => `
    -

    ${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}

    -

    ${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0) - .map(source => escapeHtml(source.toolId)).join(', ')}.

    -

    ${item.evidence === 'shared-framework-reference' - ? 'Belegte Konvergenz: Mindestens zwei Quellen nennen dieselbe Framework-Referenz.' - : 'Nur thematische Nähe innerhalb der Domain; ein gemeinsamer Finding ist nicht belegt und bedarf Validierung.'}

    -
    `).join('') || '

    Keine quellenübergreifende Konvergenz belegt.

    '}
    -
    `; - - const bundles = `
    -

    Maßnahmenbündel

    -

    Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.

    - ${projection.bundles.length ? ` - ${projection.bundles.map(bundle => ``).join('')}
    Framework-ReferenzFindings
    ${escapeHtml(bundle.key)}${integer(bundle.members.length)}
    ` - : '

    Keine mehrfach belegte Framework-Referenz.

    '} -

    Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.

    -
    `; - - const remediationSection = remediation ? `
    -

    Remediation-Status

    -

    - Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind - kein technischer Nachweis: Eine Maßnahme gilt erst dann als - behoben, wenn ein Rescan das belegt. -

    -
    -
    ${integer(remediation.items)}Maßnahmen in Arbeit
    -
    ${integer(remediation.withoutOwner)}ohne Owner
    -
    ${integer(remediation.withoutDue)}ohne Termin
    -
    ${integer(remediation.riskAccepted)}Accepted Risk
    -
    - ${Object.keys(remediation.byStatus).length ? `
    ${Object.entries(remediation.byStatus) - .map(([status, count]) => `
    ${escapeHtml(status)}
    ${integer(count)}
    `) - .join('')}
    ` : ''} -
    ` : ''; - - const coverageSection = `
    -

    Abdeckung und Lücken

    -
    -
    ${integer(coverage.assessed)}bewertet
    -
    ${integer(coverage.deviations)}Deviations
    -
    ${integer(coverage.notRun)}not run
    -
    ${integer(coverage.notApplicable)}not applicable
    -
    ${integer(coverage.executionErrors)}execution errors
    -
    -

    - Von insgesamt ${integer(coverage.total)} Ergebnissen konnten ${integer(coverage.assessed)} - bewertet werden. Nicht ausgeführte, nicht anwendbare und fehlerhaft ausgeführte - Checks zählen weder als bestanden noch als Deviation. -

    - - - - ${coverage.perSource.map(source => ` - - - - - - `).join('')} -
    Abdeckung je Quelle
    QuelleErgebnissebewertetnot runnot run Anteil
    ${escapeHtml(source.toolId)}${integer(source.total)}${integer(source.assessed)}${integer(source.notRun)}${source.total ? `${percent(source.notRun / source.total)} %` : DASH}
    -
    `; - - const sources = `
    -

    Source Overview

    - - - - - - - ${projection.scoreSources.length ? projection.scoreSources.map(source => ` - - - - - - - `).join('') : ``} -
    Score-Beitrag je Quelle
    QuelleStandBeitragMethodePassDeviations
    ${escapeHtml(source.toolId)}${germanDate(source.observedAt)}${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}${escapeHtml(SCORE_METHOD_LABEL[source.method])}${integer(source.passed)}${integer(source.deviations)}
    ${DASH}
    - ${projection.scoreSources.some(source => source.method === 'inverted-native') - ? '

    Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als 100 − Risk in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.

    ' - : ''} -
    `; - + const domain = projection.environment.find(fact => fact.key === 'Domain')?.value; + const dc = projection.environment.find(fact => fact.key === 'Domain Controller')?.value; + const gpo = projection.gpo; + const gpoNote = gpo && projection.domains.some(item => item.domainId === 'gpo') + ? `

    Die GPO-Kachel zählt zugeordnete Ergebniszeilen. Die gesonderte GPO-Evaluation zählt ${count(gpo.counts.Fail)} abweichende Vergleiche zu ${count(gpo.affectedSettings)} Einstellungen. Diese Zähleinheiten sind nicht gleichzusetzen.${gpo.scopeLimited ? ' Die Evaluation bezieht sich auf die importierten GPO-Backups.' : ''}

    ` + : ''; return ` - - - - -Management Overview · ${escapeHtml(projection.project.name)} + +ASSESSOR · Management Report · ${escapeHtml(projection.project.name)} - - -${head} -${posture} -${convergence} -${coverageSection} -${priorities} -${bundles} -${remediationSection} -${sources} - -`; +:root{--ink:#17171c;--muted:#5c6069;--navy:#123047;--line:#cfd1d7;--paper:#fff;--surface:#f5f5f7;--orange:#df752d;--heat-0:#e7e8ec;--heat-1:#f8dfcb;--heat-2:#edb68d;--heat-3:#e68f51} +*{box-sizing:border-box}body{margin:0;background:#e7e7eb;color:var(--ink);font:400 14px/1.45 Inter,Arial,sans-serif} +.sheet{width:210mm;min-height:297mm;margin:16px auto;padding:12mm 16mm 11mm;background:var(--paper);display:flex;flex-direction:column} +.mast{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:2px solid var(--navy)} +.brand{color:var(--navy);font-size:19px;font-weight:700;letter-spacing:.06em}.mast-note{color:var(--navy);text-align:right;font-size:12px;font-weight:600} +.kicker{margin:20px 0 7px;color:var(--orange);font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase} +h1{margin:0;color:var(--navy);font-size:30px;line-height:1.13;font-weight:600}h2{margin:0;color:var(--navy);font-size:18px;font-weight:600} +p{margin:0 0 8px}.subtitle{margin:8px 0 14px;color:var(--muted);font-size:14px} +.meta{display:flex;flex-wrap:wrap;gap:6px 18px;padding:8px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:var(--muted);font-size:14px} +.meta strong{color:var(--ink);font-weight:600}.facts{display:grid;grid-template-columns:repeat(3,1fr);margin:21px 0 26px;border-top:2px solid var(--navy);border-bottom:1px solid var(--line)} +.fact{padding:13px 15px;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact strong{display:block;color:var(--navy);font-size:28px;line-height:1.12;font-weight:600;font-variant-numeric:tabular-nums}.fact span{display:block;margin-top:5px;font-size:14px} +.section-head{display:flex;align-items:end;justify-content:space-between;gap:15px;margin-bottom:11px}.section-head p{max-width:260px;margin:0;color:var(--muted);font-size:14px;text-align:right} +.heatmap{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin:0;padding:0;list-style:none}.heatmap li{min-height:104px;padding:11px 12px 10px;display:flex;flex-direction:column;border:1px solid rgba(18,48,71,.17);break-inside:avoid} +.heatmap .h0{background:var(--heat-0)}.heatmap .h1{background:var(--heat-1)}.heatmap .h2{background:var(--heat-2)}.heatmap .h3{background:var(--heat-3)}.heatmap .unknown{background:var(--surface)} +.heatmap .area{color:var(--navy);font-size:14px;line-height:1.25;font-weight:600}.heatmap .value{display:block;margin-top:auto;color:var(--navy);font-size:28px;line-height:1.08;font-weight:600;font-variant-numeric:tabular-nums}.heatmap .count{display:block;margin-top:3px;color:var(--ink);font-size:14px} +.legend{display:flex;align-items:center;flex-wrap:wrap;gap:5px 12px;margin:12px 0 0;color:var(--muted);font-size:14px}.legend .key{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.legend i{display:inline-block;width:14px;height:14px;border:1px solid rgba(18,48,71,.17)} +.legend .l0{background:var(--heat-0)}.legend .l1{background:var(--heat-1)}.legend .l2{background:var(--heat-2)}.legend .l3{background:var(--heat-3)} +.comparison{margin-top:26px;padding:15px 17px;background:var(--surface);border:1px solid var(--line);break-inside:avoid}.comparison h2{margin-bottom:13px} +.comparison-scale{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;margin-bottom:5px;color:var(--muted);font-size:12px} +.comparison-scale div{grid-column:2;display:flex;justify-content:space-between} +.comparison-list{list-style:none;margin:0;padding:0}.comparison-row{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;align-items:center;min-height:34px;font-size:14px} +.comparison-bar{display:flex;height:14px;background:#e3e3e8}.bar-pass{background:#167048}.bar-fail{background:#c60b41}.bar-other{background:#92969d} +.comparison-value{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.comparison-value span{color:var(--muted);font-weight:400} +.comparison-key{display:flex;gap:24px;margin:13px 0 0 252px;font-size:14px;color:var(--muted)}.comparison-key i{display:inline-block;width:12px;height:12px;margin-right:5px;vertical-align:-1px}.comparison-key .pass{background:#167048}.comparison-key .fail{background:#c60b41} +.notes{margin-top:23px;padding:13px 15px;background:var(--surface);border-top:2px solid var(--navy)}.notes h2{font-size:16px;margin-bottom:6px}.notes p{margin:0 0 5px;font-size:14px}.notes p:last-child{margin-bottom:0} +.foot{margin-top:auto;padding-top:10px;border-top:1px solid var(--navy);display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}.foot strong{color:var(--navy);font-weight:600} +@media(max-width:800px){body{background:#fff}.sheet{width:auto;min-height:0;margin:0;padding:18px 16px 26px}.heatmap{grid-template-columns:repeat(2,1fr)}.section-head{display:block}.section-head p{max-width:none;margin-top:4px;text-align:left}h1{font-size:26px}} +@media(max-width:480px){.heatmap,.facts{grid-template-columns:1fr}.fact{border-right:0;border-bottom:1px solid var(--line)}.fact:last-child{border-bottom:0}} +@media(max-width:720px){.comparison-scale{display:none}.comparison-row{grid-template-columns:1fr auto;gap:3px 10px;margin-bottom:12px}.comparison-label{grid-column:1/-1}.comparison-bar{grid-column:1}.comparison-value{grid-column:2}.comparison-key{margin-left:0}} +@page{size:A4;margin:0}@media print{body{background:#fff;print-color-adjust:exact;-webkit-print-color-adjust:exact}.sheet{margin:0}} +
    +
    ASSESSOR
    Management Report
    +

    Gespeicherter Assessment-Stand

    Technische Ergebnisse im Überblick

    +

    ${escapeHtml(projection.project.name)}

    +
    ${domain ? `Domain: ${escapeHtml(domain)}` : ''}Berichtsdatum: ${date(projection.generatedAt)}
    +
    +
    ${dc ? escapeHtml(dc) : DASH}Domain Controller erfasst
    +
    ${count(projection.coverage.assessed)}Ergebnisse bewertet
    +
    ${count(projection.coverage.deviations)}Abweichende Ergebnisse
    +

    Abweichungsanteil je Security Domain

    Abweichende von bewerteten Ergebnissen innerhalb des jeweiligen Bereichs.

    +
      ${projection.domains.length ? projection.domains.map(tile).join('') : '
    1. Keine Bereiche verfügbar—Keine bewerteten Ergebnisse
    2. '}
    +
    Farbstufe:0–24 %25–49 %50–74 %75–100 %
    +

    Bestandene und abweichende Ergebnisse

    + +
      ${projection.domains.map(comparisonRow).join('')}
    +
    BestandenAbweichung
    +

    Pass Rate je Security Domain; rechts: Anzahl der Abweichungen von den bewerteten Ergebnissen.

    + +
    ASSESSOR ${escapeHtml(ASSESSOR_PRODUCT_VERSION)} · ${escapeHtml(projection.project.name)}
    +
    `; } diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index b04cbed..27a3780 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -761,7 +761,7 @@ function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evid overviewItems?: DashboardFact[]; snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup; }) { - const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items); + const tileItems = (overviewItems ?? items).slice(0, 4); return

    diff --git a/src/features/dashboard/OperatingSystemInventory.tsx b/src/features/dashboard/OperatingSystemInventory.tsx index 9f6930a..0f38a88 100644 --- a/src/features/dashboard/OperatingSystemInventory.tsx +++ b/src/features/dashboard/OperatingSystemInventory.tsx @@ -36,7 +36,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: { }) { const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))]; const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) })); - const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 8 - summary.length)) + const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length)) .map(entry => ({ label: entry.name, value: quantity(entry.count) })); return
    @@ -47,7 +47,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
    - {summary.length || systems.length ? [...summary, ...systems].map((item, index) =>
    + {summary.length || systems.length ? [...summary, ...systems].slice(0, 4).map((item, index) =>
    {item.label}
    {item.value}
    ) :
    Inventory
    —
    }
    diff --git a/src/styles/ui-tokens.css b/src/styles/ui-tokens.css index a2b8a3c..06d5943 100644 --- a/src/styles/ui-tokens.css +++ b/src/styles/ui-tokens.css @@ -67,6 +67,7 @@ --tile-inset: var(--space-4); --tile-compact-width: 186px; --tile-double-compact-width: calc(var(--tile-compact-width) * 2); + --tile-environment-min-width: 260px; --tile-content-width: 358px; --tile-content-min-height: 223px; --tile-card-shadow: none; diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index c44cec8..6ee89c4 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor describe('ASSESSOR version in the brand header', () => { it('shows the centrally maintained product version and opens About from the brand', () => { - expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0'); + expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.1'); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); diff --git a/tests/exports/management-overview-report.test.ts b/tests/exports/management-overview-report.test.ts index 5a94819..8c76cc4 100644 --- a/tests/exports/management-overview-report.test.ts +++ b/tests/exports/management-overview-report.test.ts @@ -1,248 +1,72 @@ import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report'; import type { ReportProjection } from '../../src/domain/report-projection'; function projection(overrides: Partial = {}): ReportProjection { return { - version: '1.1.0', - generatedAt: '2026-09-18T10:00:00.000Z', - project: { id: 'synthetic-project', name: 'Synthetic GmbH' }, - assessmentId: 'synthetic-assessment', - environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: undefined }], - overallScore: 62, - scoreSources: [], - domains: [], - priorities: [], - coverage: { - total: 0, assessed: 0, deviations: 0, passed: 0, notRun: 0, - notApplicable: 0, executionErrors: 0, unknown: 0, perSource: [] - }, + version: '1.1.0', generatedAt: '2026-09-29T10:00:00Z', + project: { id: 'synthetic', name: 'Synthetic GmbH' }, + environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: '1' }], + overallScore: 37, scoreSources: [], + domains: [ + { domainId: 'identity', domainLabel: 'Identity & Account Hygiene', passed: 204, deviations: 221, assessed: 425, passRate: 204 / 425 }, + { domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 170, deviations: 223, assessed: 393, passRate: 170 / 393 }, + { domainId: 'cloud', domainLabel: 'Cloud & Applications', passed: 0, deviations: 0, assessed: 0 } + ], + priorities: [], coverage: { total: 818, assessed: 818, deviations: 444, passed: 374, notRun: 0, + notApplicable: 0, executionErrors: 0, unknown: 0, perSource: [] }, inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} }, - findings: [], - policyEntries: [], - workItems: [], - sourcePriorities: [], - convergence: [], - bundles: [], - singleSourcePolicyActions: [], - diagnostics: [], - provenance: [], + findings: [], policyEntries: [], workItems: [], sourcePriorities: [], convergence: [], bundles: [], + singleSourcePolicyActions: [], diagnostics: [], provenance: [], + gpo: { basis: { kind: 'equal-weight', reason: 'Synthetic' }, + counts: { Fail: 100, Pass: 20, 'Not configured': 0, 'No baseline rule': 0, + 'Not comparable': 0, 'Review required': 0 }, + conflicts: 0, duplicates: 0, affectedSettings: 25, affectedBackups: 1, scopeLimited: true }, ...overrides } as ReportProjection; } -const finding = (overrides = {}) => ({ - resultProjectionId: 'synthetic-1', - domainId: 'identity', - toolId: 'purple-knight', - title: 'Synthetic indicator', - severity: 'critical' as const, - outcome: 'fail' as const, - rankBasis: { kind: 'severity' as const }, - ranked: true, - ...overrides -}); - -describe('management overview renderer', () => { - it('renders a self-contained document without script or remote resources', () => { +describe('management export', () => { + it('renders a self-contained factual heatmap and pass-rate bars from each projection', () => { const html = buildManagementOverviewHtml(projection()); - expect(html.startsWith('')).toBe(true); - expect(html).not.toMatch(/ { - expect(buildManagementOverviewHtml(projection())).toContain(''); + it('shows an unassessed domain as unknown, not zero percent', () => { + const document = new JSDOM(buildManagementOverviewHtml(projection())).window.document; + const tile = document.querySelectorAll('.heatmap li')[2]; + expect(tile.className).toBe('unknown'); + expect(tile.querySelector('.value')?.textContent).toBe('—'); + expect(document.querySelectorAll('.comparison-row')[2].textContent).not.toContain('0 %'); }); - it('keeps the binding score wording and never claims baseline compliance', () => { - const html = buildManagementOverviewHtml(projection()); - expect(html).toContain('Konformität der konfigurierten, vergleichbaren'); - expect(html).not.toMatch(/Baseline-?Compliance/i); - expect(html).not.toMatch(/Baseline-?Abdeckung/i); - }); - - it('keeps established English technical terms untranslated', () => { + it('uses other customer values, escapes names and omits GPO evaluation when absent', () => { const html = buildManagementOverviewHtml(projection({ - domains: [{ domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 1, deviations: 1, assessed: 2, passRate: 0.5 }], - priorities: [{ - domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1, - sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }] - }] + project: { id: 'other', name: '' }, + environment: [], gpo: undefined, + coverage: { ...projection().coverage, assessed: 10, deviations: 2 }, + domains: [{ domainId: 'recovery', domainLabel: 'Monitoring & Recovery', + passed: 8, deviations: 2, assessed: 10, passRate: 0.8 }] })); - expect(html).toContain('Priority Findings'); - expect(html).toContain('Overall Score'); - expect(html).toContain('Security Domains'); - expect(html).toContain('Deviations'); - expect(html).toContain('>Critical<'); - expect(html).toContain('GPO & Baseline'); - // German carries the prose around the English terms. - expect(html).toContain('Gruppiert nach Security Domain'); - }); - - it('renders an unknown value as an em dash and never as a zero', () => { - const html = buildManagementOverviewHtml(projection({ overallScore: undefined })); - expect(html).toContain('—'); - // No zero-length arc is drawn, and the readout shows no fabricated value. - expect(html).not.toContain(''); - expect(html).toContain('nicht verfügbar'); - expect(html).toContain('—'); - }); - - it('makes a positive score below two percent legible without changing its proportional arc', () => { - const html = buildManagementOverviewHtml(projection({ overallScore: 1 })); - expect(html).toContain('class="donut__small-arc"'); - expect(html).toContain('stroke-dasharray="3.393 335.899"'); - expect(buildManagementOverviewHtml(projection({ overallScore: 0.2 }))).toContain('<1/100'); - expect(buildManagementOverviewHtml(projection({ overallScore: 0 }))).not.toContain('class="donut__small-arc"'); - }); - - it('draws the donut as a full circle starting at twelve o\'clock with flat caps', () => { - const html = buildManagementOverviewHtml(projection({ overallScore: 50 })); - expect(html).toContain('rotate(-90 64 64)'); - expect(html).toContain('stroke-linecap:butt'); - expect(html).toContain('donut__track'); - }); - - it('names the ordering basis of every source and never merges them', () => { - const html = buildManagementOverviewHtml(projection({ - priorities: [{ - domainId: 'hardening', domainLabel: 'System Hardening', deviations: 2, - sources: [ - { toolId: 'cis-cat', basis: { kind: 'equal-weight', reason: 'CIS benchmarks state no criticality.' }, ranked: false, shown: [finding({ severity: 'unknown', toolId: 'cis-cat' })], total: 1 }, - { toolId: 'pingcastle', basis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' }, ranked: true, shown: [finding({ toolId: 'pingcastle', severity: 'medium', rankValue: 35, rankBasis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' } })], total: 1 } - ] - }] - })); - expect(html).toContain('Ohne Rangfolge: CIS benchmarks state no criticality.'); - expect(html).toContain('Sortiert nach rule.points (0-50+, höher ist kritischer)'); - expect(html).toContain('quellenübergreifende Rangfolge'); - }); - - it('points at the technical report instead of silently truncating a domain', () => { - const html = buildManagementOverviewHtml(projection({ - priorities: [{ - domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 9, - sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 9 }] - }] - })); - expect(html).toContain('8 weitere Deviations dieser Quelle stehen im Technical Report.'); - }); - - it('keeps GPO equal-weight and marks the score-neutral verdicts as such', () => { - const html = buildManagementOverviewHtml(projection({ - gpo: { - basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' }, - counts: { - Fail: 3, Pass: 10, 'Not configured': 40, 'No baseline rule': 5, - 'Not comparable': 2, 'Review required': 1 - }, - conflicts: 1, duplicates: 2, affectedSettings: 3, affectedBackups: 2, scopeLimited: true - } - })); - expect(html).toContain('A baseline deviation carries no native criticality.'); - expect(html).toContain('gleichgewichtig'); - expect(html).toContain('sind score-neutral und begründen keine Maßnahme'); - expect(html).toContain('trifft keine Aussage über die gesamte Domäne'); - }); - - it('omits the remediation section entirely when no reviews exist', () => { - expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status'); - }); - - it('marks remediation as self reported and not as technical proof', () => { - const html = buildManagementOverviewHtml(projection({ - remediation: { - selfReported: true, items: 4, byStatus: { Actionable: 4 }, - withoutOwner: 1, withoutDue: 2, riskAccepted: 0 - } - })); - expect(html).toContain('Remediation-Status'); - expect(html).toContain('Selbstauskunft'); - expect(html).toContain('kein technischer Nachweis'); - expect(html).toContain('Rescan'); - }); - - it('names the score method per source and explains an inverted native risk', () => { - const html = buildManagementOverviewHtml(projection({ - scoreSources: [{ - toolId: 'pingcastle', score: 55, method: 'inverted-native', weight: 1, - passed: 0, deviations: 20, assessed: 20, observedAt: '2026-07-22T00:00:00.000Z', issues: [] - }] - })); - expect(html).toContain('Nativer Risk, invertiert als 100 − Risk'); - expect(html).toContain('100 − Risk in den Index ein'); - expect(html).toContain('22.07.2026'); - }); - - it('escapes hostile content from a finding title', () => { - const html = buildManagementOverviewHtml(projection({ - priorities: [{ - domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1, - sources: [{ - toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 1, - shown: [finding({ title: '' })] - }] - }] - })); - expect(html).toContain('<img src=x onerror=alert(1)>'); - expect(html).not.toMatch(/ { - it('keeps the agreed section order and heading levels', async () => { - const { JSDOM } = await import('jsdom'); - const html = buildManagementOverviewHtml(projection({ - remediation: { - selfReported: true, items: 1, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 0 - } - })); - const { document } = new JSDOM(html).window; - expect([...document.querySelectorAll('h1')].map(node => node.textContent)) - .toEqual(['Synthetic GmbH']); - expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([ - 'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken', - 'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview' - ]); - // Every section is labelled for assistive technology. - for (const section of document.querySelectorAll('section.segment')) { - expect(section.getAttribute('aria-labelledby')).toBeTruthy(); - } - }); - - it('places no table inside a tile and never nests tables', async () => { - const { JSDOM } = await import('jsdom'); - const { document } = new JSDOM(buildManagementOverviewHtml(projection({ - coverage: { - total: 1, assessed: 1, deviations: 1, passed: 0, notRun: 0, notApplicable: 0, - executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }] - } - }))).window; - expect(document.querySelectorAll('.tile table')).toHaveLength(0); - expect(document.querySelectorAll('table table')).toHaveLength(0); - }); - - it('draws the value arc proportional to the score on a full circle', async () => { - const { JSDOM } = await import('jsdom'); - const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 25 }))).window; - const arc = document.querySelector('circle.donut__value')!; - const [drawn, gap] = arc.getAttribute('stroke-dasharray')!.split(' ').map(Number); - const circumference = 2 * Math.PI * 54; - expect(drawn + gap).toBeCloseTo(circumference, 2); - expect(drawn / circumference).toBeCloseTo(0.25, 4); - }); - - it('clamps an out-of-range score instead of drawing past the ring', async () => { - const { JSDOM } = await import('jsdom'); - const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window; - const [drawn, gap] = document.querySelector('circle.donut__value')! - .getAttribute('stroke-dasharray')!.split(' ').map(Number); - expect(gap).toBeCloseTo(0, 3); - expect(drawn).toBeCloseTo(2 * Math.PI * 54, 2); + expect(html).toContain('<Other Customer>'); + expect(html).not.toContain('synthetic.example'); + expect(html).not.toContain('gesonderte GPO-Evaluation'); + expect(html).toContain('20 %'); + expect(html).toContain('80 %'); + expect(html).not.toMatch(/| section')]; + expect(tiles).toHaveLength(6); + for (const tile of tiles) expect(tile.querySelectorAll('.client-env-facts > div').length).toBeLessThanOrEqual(4); expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']); expect(labels(domain)).toEqual(mode === 'non-numeric count' ? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS'] : ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']); - expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host']); + expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts']); // Equal domain/forest and functional-level values are distinct facts, not duplicates. if (mode !== 'unavailable') { expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2); diff --git a/tests/features/client-report-buttons.test.tsx b/tests/features/client-report-buttons.test.tsx index 9691e91..13626ee 100644 --- a/tests/features/client-report-buttons.test.tsx +++ b/tests/features/client-report-buttons.test.tsx @@ -75,7 +75,11 @@ describe('Client Info report buttons', () => { expect(management.fileName).toMatch(/management-report-.*\.html$/); expect(technical.fileName).toMatch(/technical-report-.*\.html$/); expect(management.mediaType).toBe('text/html;charset=utf-8'); - expect(management.text).toContain('Dummy technical deviation'); + expect(management.text).toContain('Abweichungsanteil je Security Domain'); + expect(management.text).toContain('Bestandene und abweichende Ergebnisse'); + expect(management.text).toContain('Dummy Customer'); + expect(management.text).not.toContain('Dummy technical deviation'); + expect(management.text).not.toContain('Overall Score'); expect(management.text).not.toContain('DUMMY-RULE-123'); expect(management.text).not.toContain('dummy-artifact-secret'); expect(technical.text).toContain('DUMMY-RULE-123'); diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index b4c1667..709b5fd 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => { const health = await fetch(`${baseUrl}/api/v1/health`); expect(await health.json()).toEqual({ - status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' + status: 'ok', application: 'ASSESSOR', productVersion: '2.9.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' }); expect(health.headers.get('access-control-allow-origin')).toBeNull(); });