Bug Fix GPO Headlines in Work...and many more :)

This commit is contained in:
Michael 2026-09-24 09:52:57 +02:00
parent 83284c2183
commit 0aadb81027
39 changed files with 1646 additions and 73 deletions

View File

@ -2,6 +2,14 @@
Status: lebendes Projektdokument Status: lebendes Projektdokument
Client-Info-HTML-Reports 2026-09-23 (Phase 7 UI-Anbindung, neuer querschnittlicher A/B-Deliverable; in Arbeit): Zwei flache Buttons erzeugen aus dem gewaehlten Timeslot getrennte, statische HTML-Dateien fuer Management und Technik. Die Report-Projektion 1.1.0 entsteht erst beim Export aus allen aktuellen Scanner- und Policy-Ergebnissen; der sichtbare Source-Filter veraendert sie nicht. Der bestehende Management-Renderer enthaelt keine technischen Einzelwerte oder Asset-IDs; der neue Technical-Renderer zeigt Result-Inventar, Findings, Originalfelder, Ist/Soll, GPO-Register samt retained Backup-Anweisungen, Diagnostics und vollstaendigen Result-Index. Unbelegte Angaben bleiben ausdruecklich unbekannt, GPO-Backups beweisen keinen effektiven Endpunktzustand, Review-Schritte sind unverifizierte Selbstauskunft. Kein Import-, Schema-, Persistenz- oder Result-DB-Disclosure-Eingriff. Synthetische Klickpfad- und Renderertests sowie beide Produktionsbuilds bestanden; weitere Vollstaendigkeitspruefung fuer Diagnostics, Reimport-Metadaten und alle Quellformate sowie visuelle Light-/Dark-Abnahme im Dummy bleiben offen. Rueckwaertskompatible Capability als Minorversion 2.6.0; Phase 7/13C und der A/B-Deliverable werden dadurch nicht als abgeschlossen markiert.
Environment-Results-Tabellenueberschriften 2026-09-23 (Phase 7 mit erhaltener Phase-13C-Quellprovenienz, Nutzerkorrektur): Das 2.5.14-Fenster setzte eine aus dem ersten Unterabschnitt stammende Abschnittsueberschrift wie `Honey Pot` ueber weitere, fachlich andere Tabellen. Die reine Leseprojektion verbindet nun jede native Tabelle innerhalb exakt desselben Scanlaufs, derselben Importdatei und desselben Abschnitts ueber den bereits erhaltenen internen Tabellenindex mit der zugehoerigen Quell-Unterabschnittsueberschrift. Mehrdeutige Labels bleiben beim nativen Tabellennamen; ein generischer Zaehlname wie `Tabelle 40` wird bei eindeutigem Unterabschnitt nicht als Ueberschrift ausgegeben. Wenn ein breiter Abschnitt faelschlich den Namen seines ersten Unterabschnitts traegt, wird der technische Abschnittsschluessel lesbar als uebergeordneter Titel dargestellt. Abschnitts- und Snapshotmetadaten besitzen je Zeile nur noch ein Label-Wert-Paar; native Spaltenkoepfe bleiben mit ihren Daten in derselben horizontal scrollbaren Tabelle. Die sichtbare Zeile `Source table index` entfaellt, die technische Zuordnung bleibt intern erhalten. 32 fokussierte synthetische Import-, Klickpfad-, Konflikt- und Versionsvertragstests, beide Produktionsbuilds, Local-only- und Designcheck bestanden. Keine Kundendateninspektion, Bestandsmutation, Import-, Persistenz- oder Result-DB-Aenderung. Phase 7/13C bleiben in Arbeit; reale Theme-Abnahme nur im freigegebenen Dummy noch offen. Patchversion 2.5.15.
Environment-Detail-Results Korrektur 2026-09-23 (Phase 7, mit read-only Scanner Context aus Phase 13C): Die vorherige 2.5.13-Umsetzung duplizierte im Results-Fenster nur die Kachelzusammenfassung und erfuellte damit die aktuelle Nutzeranforderung nicht. Die acht Buttons behalten Kachel, Fensterbedienung und Fokusvertrag, zeigen im Fenster aber jetzt die tatsaechlich gespeicherten Originalabschnitte mit allen Quelltabellenzeilen, Prosa, Zustand und Herkunft sowie die zugeordneten Einzelbeobachtungen mit getrenntem Original-/Normalwert. Source Overview enthaelt zusaetzlich die Quell- und Scan-Score-Beitraege, die vollstaendigen Quell-Findingfelder sowie alle behaltenen Abschnitte/Beobachtungen, auch nicht zuordenbare. Betriebssysteme behalten ihre gruppierte Tabelle und ergaenzen die nativen Tabellen. Umfangreiche Tabellen, Beobachtungen, Abschnitte und Findings werden in nachladbaren Bloecken vollstaendig zugaenglich; fehlender Kontext bleibt ausdruecklich leer statt erfunden. Reine bestehende Snapshot-/Record-Leseprojektion ohne Import-, Persistenz-, Result-DB-Disclosure-, Scoring- oder Reimportaenderung. Ausschliesslich synthetische Verhaltensdaten, kein Zugriff auf Kundendaten; fokussierte Interaktions-/Quelltests, Versionsvertrag, beide Produktionsbuilds, Local-only- und Designcheck bestanden. Reale Light-/Dark-Ansicht ist wegen der Dummy-Zugriffsgrenze nicht abgenommen. Phase 7/13C bleiben in Arbeit. Patchversion 2.5.14.
Environment-Detail-Results 2026-09-23 (Phase 7, Nutzerwunsch, umgesetzt und synthetisch verifiziert): Alle acht bestehenden Environment-Detail-Kacheln (Source Overview, Directory, Resilience, Inventory, Password policy, Certificates & PKI, Source metrics und Operating systems) verwenden denselben `Results`-Button und dasselbe verschiebbare/maximierbare Floating Window wie zuvor Operating systems. Das Fenster wiederholt jeweils ausschliesslich die bereits sichtbaren Quelldaten der Kachel; unbekannte Fakten bleiben `—`, die Source-Overview-Drilldowns und die Scoring-Methodology-Aktion bleiben erhalten. Jeder Button hat den kachelspezifischen Dialognamen, den sichtbaren Open-Zustand und die Fokus-Rueckgabe nach dem Schliessen. Source Overview darf bei schmaler Kachelbreite umbrechen. 42 fokussierte Tests, darunter ein synthetischer Durchlauf aller acht normalen Buttons, sowie Client-/Server-Produktionsbuild, Local-only- und Designsprachenpruefung bestanden. Gesamtsuite: 1.453 bestanden, 13 optionale Skips und dieselben fuenf bekannten fachfremden Vertragsfehler. Laufende App auf 5173 und PostgreSQL-Backend auf 4173 erreichbar; Backend meldet Produktversion 2.5.13. Keine Kundendateninspektion, Import-, Persistenz-, Score- oder Reimportaenderung. Light/Dark wurde nicht an einem realen Mandanten visuell abgenommen; Token- und Strukturvertrag sind geprueft. Patchversion 2.5.13.
Verifikation 2.5.12: 69 fokussierte Tests und beide Produktionsbuilds bestanden; die Gesamtsuite vor dem zusaetzlichen Routentest meldete 1.451 bestanden, 13 optionale Skips und dieselben fuenf bereits vorbestehenden fachfremden Vertragsfehler. Local-only-Sicherheitspruefung und Designsprachenpruefung bestanden. Laufendes lokales Backend auf Port 4173 meldet 2.5.12 mit PostgreSQL, App auf Port 5173 antwortet mit HTTP 200. Kein Zugriff auf den gemeldeten Kundenscope; dessen tatsaechliche Ansicht bleibt vom Nutzer zu pruefen. Verifikation 2.5.12: 69 fokussierte Tests und beide Produktionsbuilds bestanden; die Gesamtsuite vor dem zusaetzlichen Routentest meldete 1.451 bestanden, 13 optionale Skips und dieselben fuenf bereits vorbestehenden fachfremden Vertragsfehler. Local-only-Sicherheitspruefung und Designsprachenpruefung bestanden. Laufendes lokales Backend auf Port 4173 meldet 2.5.12 mit PostgreSQL, App auf Port 5173 antwortet mit HTTP 200. Kein Zugriff auf den gemeldeten Kundenscope; dessen tatsaechliche Ansicht bleibt vom Nutzer zu pruefen.
PingCastle-Titel-/ID-Korrektur 2026-09-23 (Phase 7/13C mit Disclosure-Grenze aus Phase 12/13B, Nutzerregression): Die vorherige 2.5.11-Abnahme erfasste nur unterschiedliche Titel innerhalb desselben kanonischen Occurrence; der gemeldete HTML-/XML-Zweiberichtsfall blieb dadurch falsch. Work gleicht nun ausschliesslich PingCastle-Vorkommnisse mit derselben Projekt-, Berichtsdatum-, Dateistamm- und nativen Regel-ID ab und bevorzugt den eindeutigen fachlichen Titel des zugehoerigen XML-/HTML-Berichts. Bei widerspruechlichen gleichwertigen Titeln wird nicht geraten; der native HTML-Checktitel bleibt als `Native source heading` erhalten. Die Result DB gibt oeffentlich dokumentierte PingCastle-Regel-IDs anhand des erweiterten offiziellen Katalogs frei und bewahrt deren native Schreibweise (`S-oldNtlm`); unbekannte IDs bleiben gesperrt. Disclosure Policy 2.5.0, Patchversion 2.5.12. Keine Kunden-/Bestandsdaten, Migration oder Reimporte veraendert; Verifikation ausschliesslich synthetisch; auch Dummy wurde in diesem Schritt nicht gelesen. PingCastle-Titel-/ID-Korrektur 2026-09-23 (Phase 7/13C mit Disclosure-Grenze aus Phase 12/13B, Nutzerregression): Die vorherige 2.5.11-Abnahme erfasste nur unterschiedliche Titel innerhalb desselben kanonischen Occurrence; der gemeldete HTML-/XML-Zweiberichtsfall blieb dadurch falsch. Work gleicht nun ausschliesslich PingCastle-Vorkommnisse mit derselben Projekt-, Berichtsdatum-, Dateistamm- und nativen Regel-ID ab und bevorzugt den eindeutigen fachlichen Titel des zugehoerigen XML-/HTML-Berichts. Bei widerspruechlichen gleichwertigen Titeln wird nicht geraten; der native HTML-Checktitel bleibt als `Native source heading` erhalten. Die Result DB gibt oeffentlich dokumentierte PingCastle-Regel-IDs anhand des erweiterten offiziellen Katalogs frei und bewahrt deren native Schreibweise (`S-oldNtlm`); unbekannte IDs bleiben gesperrt. Disclosure Policy 2.5.0, Patchversion 2.5.12. Keine Kunden-/Bestandsdaten, Migration oder Reimporte veraendert; Verifikation ausschliesslich synthetisch; auch Dummy wurde in diesem Schritt nicht gelesen.
@ -1848,6 +1856,12 @@ Aktualisierung nach der Katalog-Ladegrenze vom 2026-09-03: Fachlich freigegebene
## 9. Bereits getroffene Architekturentscheidungen ## 9. Bereits getroffene Architekturentscheidungen
- 2026-09-23, Tabellenueberschriften im Environment-Results: Gespeicherte Tabellen- und Beobachtungsprovenienz darf fuer eine rein lesende, tabellengenaue Ueberschriftenzuordnung kombiniert werden. Der interne `sourceTableIndex` ist ein Join-Schluessel, kein leserelevantes UI-Feld. Die eindeutige native Unterabschnittsueberschrift hat Vorrang vor einem generisch nummerierten Tabellennamen; bei Konflikt wird nicht geraten. Die Zuordnung bleibt auf Scanlauf, Importdatei und Abschnitt begrenzt, ohne kanonische Werte zu veraendern.
- 2026-09-23, Korrektur Environment-Detail-Results: Der Results-Button ist eine vertiefende, rein lesende Sicht auf den vorhandenen EnvironmentSnapshot, Quell-Findingbestand und die vorhandenen Score-Beitraege, nicht die Wiederholung der Kachel. Fachgruppen erhalten Quellabschnitte/Beobachtungen aus demselben Snapshot; Source Overview bleibt die vollstaendige Auffangansicht fuer nicht zuordenbare Originalfakten und zeigt die zugrundeliegenden Findings mit Originalfeldern. Original-/Normalwerte und Herkunft werden getrennt gezeigt, grosse Mengen progressiv ohne Zeilenverlust. Die fruehere Entscheidung unmittelbar darunter zum duplizierten Fensterinhalt ist durch diese ausdrueckliche Nutzerkorrektur ersetzt; Dialogmechanik und Kachel bleiben erhalten.
- 2026-09-23, Environment-Detail-Results: Die acht Kacheln teilen sich genau die bestehende Results-Dialogmechanik der Betriebssystemkachel. Fensterinhalte sind eine zweite Leseansicht derselben bereits abgeleiteten Kacheldaten, keine neue Datenquelle, Result-DB-Abfrage oder Bewertungssemantik. Dialogaktionen verwenden den konkreten Kacheltitel fuer barrierefreie Namen; der OS-Sondertext in der gemeinsamen Fensterkomponente ist damit ersetzt.
- 2026-09-23, Korrektur zur gemeinsamen Finding-Ueberschrift: Ein PingCastle-Regel-ID-Treffer allein beweist nicht dieselbe Berichtsversion. Die Leseprojektion darf einen besseren Titel nur aus dem eindeutig gepaarten Bericht desselben Projekts, Datums und Dateistamms uebernehmen; bei gleichrangigen widerspruechlichen Titeln bleibt der eigene native Titel. Die fruehere Entscheidung fuer den kanonischen Definitionstitel gilt innerhalb eines Vorkommnisses weiter, war fuer getrennte HTML-/XML-Vorkommnisse aber unvollstaendig. Oeffentliche PingCastle-IDs werden nur gegen den dokumentierten Regelkatalog validiert und in nativer Schreibweise angezeigt. - 2026-09-23, Korrektur zur gemeinsamen Finding-Ueberschrift: Ein PingCastle-Regel-ID-Treffer allein beweist nicht dieselbe Berichtsversion. Die Leseprojektion darf einen besseren Titel nur aus dem eindeutig gepaarten Bericht desselben Projekts, Datums und Dateistamms uebernehmen; bei gleichrangigen widerspruechlichen Titeln bleibt der eigene native Titel. Die fruehere Entscheidung fuer den kanonischen Definitionstitel gilt innerhalb eines Vorkommnisses weiter, war fuer getrennte HTML-/XML-Vorkommnisse aber unvollstaendig. Oeffentliche PingCastle-IDs werden nur gegen den dokumentierten Regelkatalog validiert und in nativer Schreibweise angezeigt.
- 2026-09-23, gemeinsame Finding-Ueberschrift: Result DB und Work fuehren Scannerfindings mit dem kanonischen `FindingDefinition.title`. Vorkommnisspezifische native Titel duerfen die Work-Ueberschrift nicht mehr ueberschreiben; bei Abweichung bleiben sie als `Native source heading` in der Leseprojektion sichtbar. Diese Regel ist reine Praesentation und aendert weder Originalfeld noch Provenienz, Finding-Identitaet oder gespeicherte Daten. - 2026-09-23, gemeinsame Finding-Ueberschrift: Result DB und Work fuehren Scannerfindings mit dem kanonischen `FindingDefinition.title`. Vorkommnisspezifische native Titel duerfen die Work-Ueberschrift nicht mehr ueberschreiben; bei Abweichung bleiben sie als `Native source heading` in der Leseprojektion sichtbar. Diese Regel ist reine Praesentation und aendert weder Originalfeld noch Provenienz, Finding-Identitaet oder gespeicherte Daten.
@ -2214,6 +2228,8 @@ Abnahme 2026-08-23: 677 Standardtests bestanden, 8 optionale PostgreSQL-Integrat
## 10. Offene Entscheidungen ## 10. Offene Entscheidungen
Bericht A/B 2026-09-23: Die Nutzeranforderung beauftragt die HTML-Erzeugung direkt aus Client Info und ordnet die UI-Anbindung Phase 7 zu. Die veroeffentlichten Report-Inhalte bleiben durch `docs/REPORT_STRATEGY.md` gebunden. Offen ist die endgueltige Vollstaendigkeitsabnahme des Technical Reports ueber alle Quellformate und Reimport-/Diagnostic-Klassen; sie wird nicht aus dem Vorhandensein des Buttons abgeleitet.
Timeslotvergleich 2026-09-11: P0/P1 muessen pro Scanner/Format/GPO die belastbaren Objekt-/Scopefelder, Vergleichsgranularitaet und Negativnachweisfaehigkeit sowie die konkrete Reimportbetroffenheit bestimmen. Speicher-/API-Vertrag und Uebergang fuer offene Verpflichtungen und auditierte Scopeentscheidungen sind vor Migration festzulegen. Native Teilpopulationen duerfen nur mit separat belegten Beitraegen verglichen werden; ein zusaetzlicher risikogewichteter Expositionsindex ist nicht freigegeben. Siehe Detailentscheidungen in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md). Timeslotvergleich 2026-09-11: P0/P1 muessen pro Scanner/Format/GPO die belastbaren Objekt-/Scopefelder, Vergleichsgranularitaet und Negativnachweisfaehigkeit sowie die konkrete Reimportbetroffenheit bestimmen. Speicher-/API-Vertrag und Uebergang fuer offene Verpflichtungen und auditierte Scopeentscheidungen sind vor Migration festzulegen. Native Teilpopulationen duerfen nur mit separat belegten Beitraegen verglichen werden; ein zusaetzlicher risikogewichteter Expositionsindex ist nicht freigegeben. Siehe Detailentscheidungen in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md).
Accepted-Risk-Entscheidung 2026-09-10: Der Nutzer hat keine bestehenden Freigaberegeln und beauftragt einen Best-Practice-Ausgangspunkt. Getrennter Risk Owner/Genehmiger, belegte externe Zustimmung, Befristung und Wiedervorlage sind Pflicht fuer eine Annahme. Es wird keine unbelegte allgemeingueltige Hoechstlaufzeit vorgegeben. Die lokale Ablage erfolgt additiv in `assessor_reviews.data.riskAcceptance` mit append-only Versionssnapshots. Fuer Phase 14 sind SSO-gebundene Akteure, kundengebundene Rollen, normalisierte Entscheidungs-/Versions-/Freigabe-/Evidenztabellen, manipulationsnachweisbare Aufbewahrung und serverseitige Fristenueberwachung vorgesehen; Umsetzungskriterien stehen in `docs/RISK_ACCEPTANCE.md`. Keine organisatorische Freigabe oder rechtliche Wirksamkeit wird aus einem ausgefuellten Formular abgeleitet. Accepted-Risk-Entscheidung 2026-09-10: Der Nutzer hat keine bestehenden Freigaberegeln und beauftragt einen Best-Practice-Ausgangspunkt. Getrennter Risk Owner/Genehmiger, belegte externe Zustimmung, Befristung und Wiedervorlage sind Pflicht fuer eine Annahme. Es wird keine unbelegte allgemeingueltige Hoechstlaufzeit vorgegeben. Die lokale Ablage erfolgt additiv in `assessor_reviews.data.riskAcceptance` mit append-only Versionssnapshots. Fuer Phase 14 sind SSO-gebundene Akteure, kundengebundene Rollen, normalisierte Entscheidungs-/Versions-/Freigabe-/Evidenztabellen, manipulationsnachweisbare Aufbewahrung und serverseitige Fristenueberwachung vorgesehen; Umsetzungskriterien stehen in `docs/RISK_ACCEPTANCE.md`. Keine organisatorische Freigabe oder rechtliche Wirksamkeit wird aus einem ausgefuellten Formular abgeleitet.
@ -2239,6 +2255,14 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 11. Risiken ## 11. Risiken
- 2026-09-23, HTML-Reports: Die Management-Datei darf keine internen Source-IDs, Assets oder technischen Rohwerte enthalten, auch wenn beide Renderings dieselbe interne Projektion nutzen. Separate Dateien und ein synthetischer Leckagetest sichern die Grenze. Der Technical Report kann nur gespeicherte Originalfelder und GPO-Anweisungen zeigen; fehlende Ist-/Sollstruktur, Source-Evidence oder Parserdiagnose wird nicht erfunden. Ein umfassender Vollstaendigkeits- und Theme-Test im freigegebenen Dummy ist noch offen; bis dahin gilt die A/B-Abnahme nicht als abgeschlossen.
- 2026-09-23, Environment-Unterabschnitte: Ein generischer Tabellenname und eine darueber stehende Abschnittsueberschrift beweisen nicht dieselbe fachliche Gruppe. Nur ein eindeutiger, exakt quellengebundener Unterabschnitt benennt die Tabelle neu; bei Konflikten bleibt ihr nativer Name stehen. Eine aus dem ersten Unterabschnitt uebernommene breite Abschnittsbeschriftung darf nicht andere Tabellen falsch vereinnahmen. Die synthetischen Regressionen sichern den Klickpfad, Dateiscope und Konfliktfall; realer Kundenscope wurde nicht gelesen. Schmale Fenster behalten interne horizontale Tabellenbewegung ohne Abschneiden von Spalten.
- 2026-09-23, vertiefte Environment-Results: Ein Scannerabschnitt kann mehreren fachlichen Kacheln dienen oder nicht eindeutig zuordenbar sein. Mehrfachsicht ist erlaubt; unbekannte Abschnitte bleiben garantiert in Source Overview statt unter einer erfundenen Kategorie. Tabellen-/Abschnitts-/Beobachtungsseiten werden nur im geoeffneten Fenster aufgebaut und schrittweise erweitert, ohne Originalwerte zu kuerzen. Die Ansicht ist intern und uebernimmt keine Freigabegrenze der LLM-faehigen Result DB. Datenfehler oder fehlender Snapshot werden sichtbar gemeldet. Mit synthetischen Daten und Design-Tokens verifiziert; eine reale Light-/Dark-Abnahme darf nur im freigegebenen Dummy erfolgen und ist noch offen.
- 2026-09-23, Environment-Detail-Results: Mehr Buttons duerfen in schmalen Kacheln weder Titel noch Aktionen abschneiden. Gemeinsame Header-/Action-Wrap-Regeln und die vorhandene interne Kachelscrollflaeche halten die Bedienung erreichbar; synthetische Interaktion und Designcheck sind bestanden, eine Live-Light-/Dark-Abnahme im freigegebenen Dummy steht noch aus. Der Fensterinhalt dupliziert nur die aktuelle Leseprojektion und darf keine zusaetzlichen Kundenattribute nachladen.
- 2026-09-23, PingCastle-Berichtspaarung: Derselbe Dateistamm und dasselbe Berichtsdatum koennen theoretisch mehrere Importe umfassen. Widerspruechliche gleichrangige Titel werden deshalb nicht vereinheitlicht; ohne passende oeffentliche Regel-ID bleibt die Result-DB-ID aus Datenschutzgruenden leer. Die synthetischen Tests decken die HTML-Auswahl mit gepaartem XML sowie unpassende und widerspruechliche Berichte ab. Eine Abnahme im gemeldeten Kundenscope ist ausdruecklich nicht erfolgt. - 2026-09-23, PingCastle-Berichtspaarung: Derselbe Dateistamm und dasselbe Berichtsdatum koennen theoretisch mehrere Importe umfassen. Widerspruechliche gleichrangige Titel werden deshalb nicht vereinheitlicht; ohne passende oeffentliche Regel-ID bleibt die Result-DB-ID aus Datenschutzgruenden leer. Die synthetischen Tests decken die HTML-Auswahl mit gepaartem XML sowie unpassende und widerspruechliche Berichte ab. Eine Abnahme im gemeldeten Kundenscope ist ausdruecklich nicht erfolgt.
- 2026-09-23, gemeinsame Finding-Ueberschrift: Ein kanonischer Definitionstitel kann von einer konkreten Scannerdatei abweichen oder dort zusaetzliche vorkommnisspezifische Zahlen enthalten. Work verwendet ihn bewusst als gemeinsame fachliche Ueberschrift, darf den nativen Quelltitel aber nicht verlieren oder als kanonische Identitaet behandeln. Die synthetische Regression prueft deshalb beide normalen Work-Projektionen, die sichtbare native Ueberschrift und das unveraenderte Originalfeld. Datenschutzbedingte Result-DB-Redaktion bleibt eine separate Ausgabeschicht; Work uebernimmt den kanonischen Titel, nicht die externe Disclosure-Projektion. - 2026-09-23, gemeinsame Finding-Ueberschrift: Ein kanonischer Definitionstitel kann von einer konkreten Scannerdatei abweichen oder dort zusaetzliche vorkommnisspezifische Zahlen enthalten. Work verwendet ihn bewusst als gemeinsame fachliche Ueberschrift, darf den nativen Quelltitel aber nicht verlieren oder als kanonische Identitaet behandeln. Die synthetische Regression prueft deshalb beide normalen Work-Projektionen, die sichtbare native Ueberschrift und das unveraenderte Originalfeld. Datenschutzbedingte Result-DB-Redaktion bleibt eine separate Ausgabeschicht; Work uebernimmt den kanonischen Titel, nicht die externe Disclosure-Projektion.
@ -2551,6 +2575,14 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll ## 12. Änderungsprotokoll
2026-09-23: Client Info erhielt Management Report und Technical Report als getrennte HTML-Downloadaktionen. Beide lesen den ausgewaehlten Timeslot vollstaendig ueber die API und ignorieren den momentanen Source-Filter. Report-Projektion 1.1.0 traegt interne technische Fakten nur fuer den Technical-Cut; der Management-Renderer gibt keine Assets aus. Der neue Technical-Renderer listet gespeicherte Originalfelder, GPO-Quellanweisungen, Ist-/Sollwerte, Provenienz, Inventory, Diagnostics und Result-Index; belegte Felddifferenzen bleiben feldspezifisch. Synthetische Klickpfad-/Renderer-Regressionen und beide Produktionsbuilds bestanden; Vollstaendigkeits-/Theme-Abnahme bleibt offen. Kein Kundendatenzugriff oder Bestandsmutation. Minorversion 2.6.0.
2026-09-23: Environment-Results-Tabellenkoepfe berichtigt (Phase 7/13C). Eindeutige Quell-Unterabschnittslabels werden tabellengenau aus behaltenen Beobachtungen derselben Lauf-/Datei-/Abschnittsidentitaet gelesen. `Honey Pot` steht nicht mehr als Sammelueberschrift ueber `Account analysis`; generische `Tabelle n`- und sichtbare `Source table index`-Zeilen entfallen. Zweispaltige Label-Wert-Metadaten und horizontal zusammenbleibende native Kopf-/Datenzellen verbessern die schmale Ansicht. 32 fokussierte synthetische Tests sowie beide Produktionsbuilds, Local-only- und Designcheck bestanden; keine Bestandsmutation oder Kundeninspektion. Patchversion 2.5.15.
2026-09-23: Nutzerkorrektur fuer alle acht Environment-Detail-Results (Phase 7/13C): Statt duplizierter Kachelwerte zeigen die Fenster gespeicherte native Berichtstabellen, Prosa, Original-/Normalbeobachtungen, Erfassungszustand und Quellherkunft; Source Overview umfasst auch unbekannte Abschnitte, die zugrundeliegenden Quell-Findingfelder sowie Source-/Scan-Score-Beitraege. Progressive Zeilen-/Abschnitts-/Findinganzeige bewahrt alle Fakten. Synthetische Klickpfad-, Gruppierungs- und Mehr-als-100-Zeilen-Regressionen sowie beide Produktionsbuilds, Versionsvertrag, Local-only- und Designcheck bestanden. Keine Kundendateninspektion oder Bestandsmutation; keine reale Theme-Abnahme. Die in 2.5.13 dokumentierte Inhaltduplikation ist durch 2.5.14 abgeloest.
2026-09-23: Results-Button der Operating-systems-Kachel als gemeinsame Environment-Detail-Aktion auf alle acht Kacheln ausgedehnt (Phase 7). Quelle und sechs Faktgruppen oeffnen denselben Floating-Window-Typ mit ihren unveraenderten angezeigten Werten; kachelspezifische Dialoglabels, Fokus-Rueckgabe, Scoring-Aktion und schmaler Umbruch sind erhalten. 42 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designcheck bestanden; Gesamtlauf 1.453 bestanden, 13 Skips, fuenf vorbestehende fachfremde Fehler. Keine Kundendaten oder Bestandsmutation. Produktversion 2.5.13.
2026-09-23: Vorherigen Abschluss 2.5.11 fuer den gemeldeten PingCastle-Fall korrigiert (Phase 7/13C und 12/13B). Getrennte HTML-/XML-Vorkommnisse derselben Regel werden in der Work-Leseprojektion berichtsspezifisch abgeglichen; oeffentlich dokumentierte PingCastle-IDs werden in der Result DB mit nativer Gross-/Kleinschreibung sichtbar. Unbekannte IDs bleiben fail-closed, widerspruechliche Titel werden nicht erraten. Synthetische Work-/Result-DB-Regressionen, kein Kundendatenzugriff, keine Migration und kein Reimport. Patchversion 2.5.12, Disclosure Policy 2.5.0. 2026-09-23: Vorherigen Abschluss 2.5.11 fuer den gemeldeten PingCastle-Fall korrigiert (Phase 7/13C und 12/13B). Getrennte HTML-/XML-Vorkommnisse derselben Regel werden in der Work-Leseprojektion berichtsspezifisch abgeglichen; oeffentlich dokumentierte PingCastle-IDs werden in der Result DB mit nativer Gross-/Kleinschreibung sichtbar. Unbekannte IDs bleiben fail-closed, widerspruechliche Titel werden nicht erraten. Synthetische Work-/Result-DB-Regressionen, kein Kundendatenzugriff, keine Migration und kein Reimport. Patchversion 2.5.12, Disclosure Policy 2.5.0.
2026-09-23: Scanner-Ueberschriften zwischen Result DB und Work vereinheitlicht (Phase 7/13C). Work verwendet in Aggregat- und Quellansicht den kanonischen Definitionstitel, den auch die Result DB fuehrt; ein abweichender nativer Scannertext bleibt als `Native source heading` sichtbar und im kanonischen Original unveraendert. Eine synthetische PingCastle-Regression prueft den konkreten regelorientierten Quelltitel gegen den besser zuordenbaren anzahlbezogenen Result-DB-Titel in beiden Work-Pfaden. 42 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden; Gesamtsuite 1.448 bestanden, 13 optionale Skips und fuenf bekannte fachfremde Vertragsfehler. Keine Migration, Reimporte oder Bestandsmutation. Produktversion 2.5.11. 2026-09-23: Scanner-Ueberschriften zwischen Result DB und Work vereinheitlicht (Phase 7/13C). Work verwendet in Aggregat- und Quellansicht den kanonischen Definitionstitel, den auch die Result DB fuehrt; ein abweichender nativer Scannertext bleibt als `Native source heading` sichtbar und im kanonischen Original unveraendert. Eine synthetische PingCastle-Regression prueft den konkreten regelorientierten Quelltitel gegen den besser zuordenbaren anzahlbezogenen Result-DB-Titel in beiden Work-Pfaden. 42 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden; Gesamtsuite 1.448 bestanden, 13 optionale Skips und fuenf bekannte fachfremde Vertragsfehler. Keine Migration, Reimporte oder Bestandsmutation. Produktversion 2.5.11.

View File

@ -1,9 +1,8 @@
# Assessment output strategy # Assessment output strategy
Decision: 2026-09-18, explicitly approved by the user. No implementation phase is Decision: 2026-09-18, explicitly approved by the user. The 2026-09-23 user request
assigned yet; these outputs are a new deliverable that is not covered by an starts the A/B HTML deliverable through Client Info (Phase 7 UI integration,
existing plan phase. This document fixes *what is shown* before any renderer is cross-cutting report projection). Artifact C and D remain separate future work.
written.
ASSESSOR produces **three** artifacts from one assessment: ASSESSOR produces **three** artifacts from one assessment:
@ -80,7 +79,7 @@ strings. All German wording lives in the renderers.
## Shared report projection ## Shared report projection
Implemented in `src/domain/report-projection.ts` Implemented in `src/domain/report-projection.ts`
(`REPORT_PROJECTION_VERSION = '1.0.0'`), covered by (`REPORT_PROJECTION_VERSION = '1.1.0'`), covered by
`tests/domain/report-projection.test.ts`. `tests/domain/report-projection.test.ts`.
All artifacts read **one** projection built in the platform-neutral domain layer. All artifacts read **one** projection built in the platform-neutral domain layer.
@ -175,6 +174,14 @@ manifest versions.
## Report B — Technical Report ## Report B — Technical Report
The initial static renderer is implemented in `src/exports/technical-report.ts`.
It retains the current internal result projection, original Work fields when a
canonical occurrence matches, and the retained per-backup GPO instructions.
Missing source facts are labelled as unavailable; the renderer never invents
actual/expected values or an endpoint-effective state. A dedicated completeness
audit of diagnostics, reimport metadata and all source formats remains open and
must not be mistaken for a final acceptance of every section below.
Answers: what exactly is configured, what should it be, where does that stand, Answers: what exactly is configured, what should it be, where does that stand,
how does ASSESSOR know, what do I do. how does ASSESSOR know, what do I do.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.5.12", "version": "2.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assessor", "name": "assessor",
"version": "2.5.12", "version": "2.6.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.5.12", "version": "2.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -34,6 +34,8 @@ import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import'; import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility'; import { processingScopeIssues } from '../src/domain/processing-compatibility';
import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release'; import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release';
import { assembleReportProjectionInput } from '../src/features/reports/report-input-assembler';
import { buildReportProjection } from '../src/domain/report-projection';
import { compareReimportSourceHashes } from '../src/domain/reimport-source'; import { compareReimportSourceHashes } from '../src/domain/reimport-source';
import { import {
findPossibleDuplicateImportFile, findPossibleDuplicateImportFile,
@ -888,6 +890,16 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (segments[0] === 'projects' && segments[2] === 'export' && segments.length === 3 && method === 'GET') { if (segments[0] === 'projects' && segments[2] === 'export' && segments.length === 3 && method === 'GET') {
status = 200; sendJson(response, status, await repository.exportProject(segments[1]), requestId); return; status = 200; sendJson(response, status, await repository.exportProject(segments[1]), requestId); return;
} }
if (segments[0] === 'projects' && segments[2] === 'report-projection' && segments.length === 3 && method === 'GET') {
const assessmentIds = url.searchParams.getAll('assessmentIds');
const scanRunIds = url.searchParams.getAll('scanRunIds');
const input = await assembleReportProjectionInput(repository, {
projectId: segments[1], generatedAt: new Date().toISOString(),
assessmentIds: assessmentIds.length ? assessmentIds : undefined,
scanRunIds: scanRunIds.length ? scanRunIds : undefined
});
status = 200; sendJson(response, status, buildReportProjection(input), requestId); return;
}
if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') { if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') {
const assessment = await readJson<Assessment>(request, maximumBodyBytes); const assessment = await readJson<Assessment>(request, maximumBodyBytes);
await repository.saveAssessment(assessment); await repository.saveAssessment(assessment);

View File

@ -10,6 +10,7 @@ import type {
import type { ProjectDataExport } from '../persistence/assessor-repository'; import type { ProjectDataExport } from '../persistence/assessor-repository';
import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/environment-context'; import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/environment-context';
import type { ImportedDataset } from '../domain/dataset'; import type { ImportedDataset } from '../domain/dataset';
import type { ReportProjection } from '../domain/report-projection';
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline'; import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow'; import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
import { import {
@ -148,6 +149,13 @@ export class AssessorApiClient {
return this.request(`/projects/${encodeURIComponent(projectId)}/export`); return this.request(`/projects/${encodeURIComponent(projectId)}/export`);
} }
getReportProjection(projectId: string, scanRunIds?: string[], assessmentIds?: string[]): Promise<ReportProjection> {
const query = new URLSearchParams();
scanRunIds?.forEach(id => query.append('scanRunIds', id));
assessmentIds?.forEach(id => query.append('assessmentIds', id));
return this.request(`/projects/${encodeURIComponent(projectId)}/report-projection?${query.toString()}`);
}
listProjectImportFiles(projectId: string): Promise<ImportFileRecord[]> { listProjectImportFiles(projectId: string): Promise<ImportFileRecord[]> {
return this.request(`/projects/${encodeURIComponent(projectId)}/import-files`); return this.request(`/projects/${encodeURIComponent(projectId)}/import-files`);
} }

View File

@ -136,10 +136,10 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
<h2 id={titleId}>{title}</h2> <h2 id={titleId}>{title}</h2>
<div className="floating-data-window-actions"> <div className="floating-data-window-actions">
<button type="button" onClick={() => setMaximized(value => !value)} <button type="button" onClick={() => setMaximized(value => !value)}
title={maximized ? 'Restore window' : 'Maximize window'} aria-label={maximized ? 'Restore Operating systems window' : 'Maximize Operating systems window'}> title={maximized ? 'Restore window' : 'Maximize window'} aria-label={`${maximized ? 'Restore' : 'Maximize'} ${title} window`}>
<WindowIcon maximized={maximized} /> <WindowIcon maximized={maximized} />
</button> </button>
<button ref={closeRef} type="button" onClick={onClose} title="Close" aria-label="Close Operating systems window"> <button ref={closeRef} type="button" onClick={onClose} title="Close" aria-label={`Close ${title} window`}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button> </button>
</div> </div>

View File

@ -0,0 +1,89 @@
import type { UnifiedResultProjection } from './unified-result';
import type { ReportDomain, ReportPolicyEntry } from './report-projection';
export const REPORT_CORRELATION_VERSION = '1.0.0';
export interface ReportDomainConvergence {
domainId: string;
sources: Array<{ toolId: string; assessed: number; deviations: number }>;
sourceCount: number;
evidence: 'shared-framework-reference' | 'domain-only';
}
export interface ReportRemediationBundle {
key: string;
namespace: string;
members: string[];
toolIds: string[];
domainIds: string[];
}
export interface ReportSingleSourcePolicyAction {
policyEntryId: string;
backupInstanceId: string;
}
export function correlateReportResults(
results: readonly UnifiedResultProjection[],
domains: ReadonlyArray<ReportDomain>,
domainOf: (result: UnifiedResultProjection) => string,
policyEntries: readonly ReportPolicyEntry[]
): {
convergence: ReportDomainConvergence[];
bundles: ReportRemediationBundle[];
singleSourcePolicyActions: ReportSingleSourcePolicyAction[];
} {
const convergence = domains.map(domain => {
const inDomain = results.filter(result => domainOf(result) === domain.id);
const toolIds = [...new Set(inDomain.map(result => result.sourceTool ?? 'unknown'))].sort();
const sources = toolIds.map(toolId => {
const fromTool = inDomain.filter(result => (result.sourceTool ?? 'unknown') === toolId);
return {
toolId,
assessed: fromTool.filter(result => result.outcomeNormalized === 'pass' || result.isDeviation).length,
deviations: fromTool.filter(result => result.isDeviation).length
};
});
const referenceTools = new Map<string, Set<string>>();
for (const result of inDomain.filter(item => item.isDeviation)) {
for (const reference of result.frameworkReferences ?? []) {
if (!reference.value.trim()) continue;
const tools = referenceTools.get(reference.value) ?? new Set<string>();
tools.add(result.sourceTool ?? 'unknown');
referenceTools.set(reference.value, tools);
}
}
return {
domainId: domain.id,
sources,
sourceCount: sources.filter(source => source.deviations > 0).length,
evidence: [...referenceTools.values()].some(tools => tools.size >= 2)
? 'shared-framework-reference' as const : 'domain-only' as const
};
});
const byReference = new Map<string, Array<{ result: UnifiedResultProjection; namespace: string }>>();
for (const result of results.filter(item => item.isDeviation)) {
for (const reference of result.frameworkReferences ?? []) {
if (!reference.value.trim()) continue;
const members = byReference.get(reference.value) ?? [];
if (!members.some(member => member.result.resultProjectionId === result.resultProjectionId)) {
members.push({ result, namespace: reference.namespace });
}
byReference.set(reference.value, members);
}
}
const bundles = [...byReference].filter(([, members]) => members.length >= 2)
.map(([key, members]) => ({
key,
namespace: [...new Set(members.map(member => member.namespace))].sort().join(', '),
members: members.map(member => member.result.resultProjectionId).sort(),
toolIds: [...new Set(members.map(member => member.result.sourceTool ?? 'unknown'))].sort(),
domainIds: [...new Set(members.map(member => domainOf(member.result)))].sort()
}))
.sort((left, right) => right.members.length - left.members.length || left.key.localeCompare(right.key));
const singleSourcePolicyActions = policyEntries.filter(entry => entry.verdict === 'Fail'
&& !entry.conflict && !entry.duplicate && entry.backupInstanceIds.length === 1)
.map(entry => ({ policyEntryId: entry.id, backupInstanceId: entry.backupInstanceIds[0] }));
return { convergence, bundles, singleSourcePolicyActions };
}

View File

@ -2,6 +2,8 @@ import type { CanonicalOutcome, CanonicalSeverity } from './canonical-model';
import type { RemediationEvidenceState } from './timeslot-comparison'; import type { RemediationEvidenceState } from './timeslot-comparison';
import type { ToolPriorityBasisDefinition } from './tool-profile-definition'; import type { ToolPriorityBasisDefinition } from './tool-profile-definition';
import type { UnifiedResultProjection } from './unified-result'; import type { UnifiedResultProjection } from './unified-result';
import type { PolicySourceEvidence, RecordField } from './finding';
import { correlateReportResults, type ReportDomainConvergence, type ReportRemediationBundle, type ReportSingleSourcePolicyAction } from './report-correlation';
import { severityRank } from './severity-order'; import { severityRank } from './severity-order';
/** /**
@ -14,7 +16,7 @@ import { severityRank } from './severity-order';
* every German wording lives in a renderer. It is also platform-neutral: no * every German wording lives in a renderer. It is also platform-neutral: no
* clock, no storage, no network and no presentation types. * clock, no storage, no network and no presentation types.
*/ */
export const REPORT_PROJECTION_VERSION = '1.0.0'; export const REPORT_PROJECTION_VERSION = '1.1.0';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Priority basis */ /* Priority basis */
@ -131,8 +133,9 @@ export interface ReportPolicyEntry {
export interface ReportDiagnostic { export interface ReportDiagnostic {
code: string; code: string;
reason?: string;
/** Where the diagnostic was raised; keeps one observation from being restated. */ /** Where the diagnostic was raised; keeps one observation from being restated. */
origin: 'import' | 'aggregation' | 'evaluation' | 'comparison' | 'projection'; origin: 'import' | 'analysis' | 'aggregation' | 'evaluation' | 'comparison' | 'projection';
count: number; count: number;
} }
@ -190,6 +193,8 @@ export interface ReportProjectionInput {
overallScore?: number; overallScore?: number;
environment?: ReportFact[]; environment?: ReportFact[];
policyEntries?: ReportPolicyEntry[]; policyEntries?: ReportPolicyEntry[];
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
policyScopeLimited?: boolean; policyScopeLimited?: boolean;
reviews?: ReportReviewInput[]; reviews?: ReportReviewInput[];
diagnostics?: ReportDiagnostic[]; diagnostics?: ReportDiagnostic[];
@ -342,9 +347,18 @@ export interface ReportProjection {
* complete second sheet. Ordering lives in `priorities`, never here. * complete second sheet. Ordering lives in `priorities`, never here.
*/ */
findings: ReportFindingRef[]; findings: ReportFindingRef[];
/** Internal technical evidence, retained only in the Technical Report cut. */
technicalResults?: UnifiedResultProjection[];
technicalResultDomains?: Record<string, string>;
policyEntries: ReportPolicyEntry[]; policyEntries: ReportPolicyEntry[];
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
technicalReviews?: ReportReviewInput[];
workItems: ReportWorkItem[]; workItems: ReportWorkItem[];
sourcePriorities: ReportSourcePriority[]; sourcePriorities: ReportSourcePriority[];
convergence: ReportDomainConvergence[];
bundles: ReportRemediationBundle[];
singleSourcePolicyActions: ReportSingleSourcePolicyAction[];
diagnostics: ReportDiagnostic[]; diagnostics: ReportDiagnostic[];
provenance: ReportProvenanceEntry[]; provenance: ReportProvenanceEntry[];
delta?: ReportDelta; delta?: ReportDelta;
@ -624,6 +638,7 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje
} : undefined; } : undefined;
const diagnostics = [...(input.diagnostics ?? [])]; const diagnostics = [...(input.diagnostics ?? [])];
const correlation = correlateReportResults(results, domains, domainOf, policyEntries);
for (const entry of sourcePriorities) { for (const entry of sourcePriorities) {
for (const issue of entry.issues) { for (const issue of entry.issues) {
diagnostics.push({ code: `report-priority-${issue}:${entry.toolId}`, origin: 'projection', count: 1 }); diagnostics.push({ code: `report-priority-${issue}:${entry.toolId}`, origin: 'projection', count: 1 });
@ -645,9 +660,15 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje
inventory, inventory,
remediation, remediation,
findings: deviationRefs, findings: deviationRefs,
technicalResults: results,
technicalResultDomains: Object.fromEntries(results.map(result => [result.resultProjectionId, domainOf(result)])),
policyEntries, policyEntries,
technicalPolicySources: input.technicalPolicySources,
technicalFindingFields: input.technicalFindingFields,
technicalReviews: reviews,
workItems, workItems,
sourcePriorities, sourcePriorities,
...correlation,
diagnostics, diagnostics,
provenance: input.provenance ?? [], provenance: input.provenance ?? [],
delta delta

View File

@ -308,6 +308,7 @@ export function discloseResultDbProjection(item: UnifiedResultProjection): Unifi
: undefined; : undefined;
return { return {
...item, ...item,
frameworkReferences: [],
sourceTool: tool.id, sourceTool: tool.id,
sourceToolVersion: undefined, sourceToolVersion: undefined,
sourceProfileId: tool.id, sourceProfileId: tool.id,

View File

@ -65,6 +65,13 @@ export interface UnifiedResultCategory {
sourceProfileVersion?: string; sourceProfileVersion?: string;
} }
export interface UnifiedResultFrameworkReference {
namespace: string;
value: string;
sourceFieldKey?: string;
sourceLabel?: string;
}
export interface UnifiedResultProjection { export interface UnifiedResultProjection {
resultProjectionId: string; resultProjectionId: string;
sourceKind: UnifiedResultSourceKind; sourceKind: UnifiedResultSourceKind;
@ -175,6 +182,7 @@ export interface UnifiedResultProjection {
evaluatedAt?: string; evaluatedAt?: string;
createdAt: string; createdAt: string;
categories: UnifiedResultCategory[]; categories: UnifiedResultCategory[];
frameworkReferences: UnifiedResultFrameworkReference[];
} }
export interface UnifiedResultQuery { export interface UnifiedResultQuery {
@ -364,6 +372,20 @@ export function scannerResultCategories(
})); }));
} }
export function scannerResultFrameworkReferences(occurrence: FindingOccurrence): UnifiedResultFrameworkReference[] {
return occurrence.fieldMappings.filter(mapping => mapping.targetRole === 'framework-reference')
.flatMap(mapping => {
const sourceFieldKey = mapping.sourceFieldKeys[0];
const sourceLabel = occurrence.originalFields.find(field => field.key === sourceFieldKey)?.label;
const values = typeof mapping.normalizedValue === 'string' ? [mapping.normalizedValue]
: Array.isArray(mapping.normalizedValue) ? mapping.normalizedValue.filter((value): value is string => typeof value === 'string') : [];
return values.filter(value => value.trim() !== '').map(value => ({
namespace: occurrence.sourceIdentity.tool.id,
value, sourceFieldKey, sourceLabel
}));
});
}
function mappingConfidence(occurrence: FindingOccurrence): number { function mappingConfidence(occurrence: FindingOccurrence): number {
const scores = [ const scores = [
occurrence.severity.provenance.confidence.score, occurrence.severity.provenance.confidence.score,
@ -436,7 +458,8 @@ export function createScannerUnifiedResult(source: ScannerUnifiedResultSource):
sourceObservedAt: source.occurrence.observedAt, sourceObservedAt: source.occurrence.observedAt,
importedAt: source.importFile.importedAt, importedAt: source.importFile.importedAt,
createdAt: source.importFile.importedAt, createdAt: source.importFile.importedAt,
categories: scannerResultCategories(source.definition, source.occurrence, source.importFile) categories: scannerResultCategories(source.definition, source.occurrence, source.importFile),
frameworkReferences: scannerResultFrameworkReferences(source.occurrence)
}; };
} }
@ -487,6 +510,7 @@ export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): Un
importedAt: occurrence.createdAt, importedAt: occurrence.createdAt,
evaluatedAt: occurrence.createdAt, evaluatedAt: occurrence.createdAt,
createdAt: occurrence.createdAt, createdAt: occurrence.createdAt,
frameworkReferences: [],
categories: [{ categories: [{
namespace: 'assessor-policy', namespace: 'assessor-policy',
categoryCode: definition.category ?? 'group-policy-baseline', categoryCode: definition.category ?? 'group-policy-baseline',
@ -606,6 +630,7 @@ export function createPolicyEvaluationUnifiedResult(
policyComparisonRelation: result.comparator.relation, policyComparisonRelation: result.comparator.relation,
policyCustomerState: result.customerState, policyCustomerState: result.customerState,
policyClassification: policyEvaluationClassification(result), policyClassification: policyEvaluationClassification(result),
frameworkReferences: [],
categories: [{ categories: [{
namespace: 'group-policy', namespace: 'group-policy',
categoryCode: 'GPOs', categoryCode: 'GPOs',
@ -778,6 +803,7 @@ export function createPolicyObservationUnifiedResult(source: PolicyObservationUn
policyGpoId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.gpoId : undefined, policyGpoId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.gpoId : undefined,
policyNativeBackupId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.backupId : undefined, policyNativeBackupId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.backupId : undefined,
policySourceSequence: observation.registryPolicy?.sequence ?? observation.preference?.sequence, policySourceSequence: observation.registryPolicy?.sequence ?? observation.preference?.sequence,
frameworkReferences: [],
categories: [{ categories: [{
namespace: 'group-policy', namespace: 'group-policy',
categoryCode: observation.deliveryMechanism, categoryCode: observation.deliveryMechanism,
@ -886,6 +912,7 @@ export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultS
reviewStatus: 'unreviewed', reviewStatus: 'unreviewed',
importedAt: importFile.importedAt, importedAt: importFile.importedAt,
createdAt: importFile.importedAt, createdAt: importFile.importedAt,
frameworkReferences: [],
categories: [{ categories: [{
namespace: 'environment-subject', namespace: 'environment-subject',
categoryCode: disclosure.subjectType, categoryCode: disclosure.subjectType,

View File

@ -62,6 +62,7 @@ export function createPolicyConsistencyUnifiedResults(
policyClassification: classification, policyClassification: classification,
policySourceCount: result.sources.length, policySourceCount: result.sources.length,
policyDiagnosticCodes: [classification === 'conflict' ? 'policy-customer-conflict' : 'policy-customer-duplicate'], policyDiagnosticCodes: [classification === 'conflict' ? 'policy-customer-conflict' : 'policy-customer-duplicate'],
frameworkReferences: [],
categories: [{ categories: [{
namespace: 'group-policy-consistency', namespace: 'group-policy-consistency',
categoryCode: classification, categoryCode: classification,

View File

@ -21,7 +21,7 @@ import { escapeHtml } from '../shared/html';
* English. The file is self-contained: every style is inline, nothing is * English. The file is self-contained: every style is inline, nothing is
* fetched when it is opened. * fetched when it is opened.
*/ */
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'; export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.1.0';
const DASH = '—'; const DASH = '—';
@ -146,7 +146,6 @@ function findingRow(finding: ReportFindingRef, ranked: boolean): string {
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''} ${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''}
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric' ${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''} ? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
${finding.affectedAsset ? `<span class="finding__asset">${escapeHtml(finding.affectedAsset)}</span>` : ''}
</span> </span>
</li>`; </li>`;
} }
@ -262,6 +261,28 @@ export function buildManagementOverviewHtml(projection: ReportProjection): strin
</div> </div>
</section>`; </section>`;
const convergence = `<section class="segment" aria-labelledby="convergence">
<h2 id="convergence">Konvergenz der Quellen</h2>
<p class="note">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
<div class="segment__body">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="tile domain">
<h3>${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
<p>${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0)
.map(source => escapeHtml(source.toolId)).join(', ')}.</p>
<p class="note">${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.'}</p>
</article>`).join('') || '<p class="empty">Keine quellenübergreifende Konvergenz belegt.</p>'}</div>
</section>`;
const bundles = `<section class="segment" aria-labelledby="bundles">
<h2 id="bundles">Maßnahmenbündel</h2>
<p class="note">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
${projection.bundles.length ? `<table class="table"><thead><tr><th>Framework-Referenz</th><th>Findings</th></tr></thead>
<tbody>${projection.bundles.map(bundle => `<tr><td>${escapeHtml(bundle.key)}</td><td class="num">${integer(bundle.members.length)}</td></tr>`).join('')}</tbody></table>`
: '<p class="empty">Keine mehrfach belegte Framework-Referenz.</p>'}
<p class="note">Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.</p>
</section>`;
const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation"> const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation">
<h2 id="remediation">Remediation-Status</h2> <h2 id="remediation">Remediation-Status</h2>
<p class="note"> <p class="note">
@ -296,12 +317,13 @@ export function buildManagementOverviewHtml(projection: ReportProjection): strin
</p> </p>
<table class="table"> <table class="table">
<caption>Abdeckung je Quelle</caption> <caption>Abdeckung je Quelle</caption>
<thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th></tr></thead> <thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th><th scope="col">not run Anteil</th></tr></thead>
<tbody>${coverage.perSource.map(source => `<tr> <tbody>${coverage.perSource.map(source => `<tr>
<td>${escapeHtml(source.toolId)}</td> <td>${escapeHtml(source.toolId)}</td>
<td class="num">${integer(source.total)}</td> <td class="num">${integer(source.total)}</td>
<td class="num">${integer(source.assessed)}</td> <td class="num">${integer(source.assessed)}</td>
<td class="num">${integer(source.notRun)}</td> <td class="num">${integer(source.notRun)}</td>
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
</section>`; </section>`;
@ -484,9 +506,11 @@ strong{font-weight:var(--fw-semibold)}
<body> <body>
${head} ${head}
${posture} ${posture}
${priorities} ${convergence}
${remediationSection}
${coverageSection} ${coverageSection}
${priorities}
${bundles}
${remediationSection}
${sources} ${sources}
</body> </body>
</html>`; </html>`;

View File

@ -0,0 +1,154 @@
import type { JsonValue } from '../domain/canonical-model';
import type { PolicySourceEvidence, RecordField } from '../domain/finding';
import type { ReportProjection, ReportReviewInput } from '../domain/report-projection';
import type { UnifiedResultProjection } from '../domain/unified-result';
import { escapeHtml } from '../shared/html';
export const TECHNICAL_REPORT_RENDERER_VERSION = '1.0.0';
const plain = (value: unknown): string => value === undefined || value === null || value === ''
? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE'));
const when = (value: string): string => Number.isFinite(Date.parse(value))
? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value);
function table(headers: string[], rows: string[][]): string {
return `<div class="table-scroll"><table><thead><tr>${headers.map(label => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string {
if (Array.isArray(value)) {
const members = `<ul class="values">${value.map(item => `<li class="${typeof item === 'string' && markedItems.includes(item) ? 'different' : ''}">${sourceValue(item, [], [])}</li>`).join('')}</ul>`;
return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members;
}
if (value && typeof value === 'object') {
const instruction = `<dl class="instruction">${Object.entries(value).map(([key, item]) => {
const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value');
return `<div><dt>${escapeHtml(key)}</dt><dd class="${marked ? 'different' : ''}">${sourceValue(item, [], markedItems)}</dd></div>`;
}).join('')}</dl>`;
return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length
? `<div class="different">${instruction}</div>` : instruction;
}
return markedFields.includes('value') ? `<span class="different">${plain(value)}</span>` : plain(value);
}
function sourceSections(sources: PolicySourceEvidence[]): string {
if (!sources.length) return '<p class="limit">Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.</p>';
return sources.map(source => `<section class="source">
<h5>GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}</h5>
<dl class="facts"><div><dt>Backup-Instanz</dt><dd>${plain(source.backupInstanceId)}</dd></div>
<div><dt>Evidence</dt><dd>${plain(source.sourceReference)}</dd></div>
<div><dt>Quellreihenfolge</dt><dd>${plain(source.sequence)}</dd></div></dl>
${source.values.map((value, index) => `<div class="source-value"><span>Anweisung ${number(index + 1)}</span>
${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}</div>`).join('')}
</section>`).join('');
}
function resultDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields?: RecordField[]): string {
const actual = result.policyActualValue;
const expected = result.policyBaselineValue;
const variants = result.policyBaselineVariantAssessment;
return `<article class="finding">
<header><h4>${plain(result.title)}</h4><span>${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}</span></header>
<dl class="facts"><div><dt>Security Domain</dt><dd>${escapeHtml(domainLabel)}</dd></div>
<div><dt>Source Rule ID</dt><dd>${plain(result.sourceRuleId)}</dd></div>
<div><dt>Technische Identität</dt><dd>${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}</dd></div>
<div><dt>Scope</dt><dd>${plain(result.policyScope ?? result.affectedAsset)}</dd></div>
<div><dt>Result Status</dt><dd>${plain(result.policyClassification ?? result.outcomeNormalized)}</dd></div>
<div><dt>Baseline</dt><dd>${plain(result.policyBaselineLabel)}</dd></div></dl>
${result.description ? `<p>${plain(result.description)}</p>` : ''}
${result.recommendation ? `<p><strong>Empfehlung:</strong> ${plain(result.recommendation)}</p>` : ''}
${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]])
: '<p class="limit">Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.</p>'}
${variants ? `<h5>Baseline-Varianten</h5><pre>${plain(variants)}</pre>` : ''}
${result.policyDiagnosticCodes?.length ? `<p><strong>Diagnostics:</strong> ${result.policyDiagnosticCodes.map(plain).join(', ')}</p>` : ''}
${result.frameworkReferences.length ? `<h5>Framework-Referenzen (exakte Quellwerte)</h5>${table(['Namespace', 'Referenz'],
result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''}
${fields?.length ? `<h5>Originale technische Felder</h5>${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''}
${review?.nextStep ? `<p><strong>Geplanter Remediation-Schritt (unverifizierte Selbstauskunft):</strong> ${plain(review.nextStep)}</p>` : ''}
<p class="meta">Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}</p>
</article>`;
}
export function buildTechnicalReportHtml(projection: ReportProjection): string {
const results = projection.technicalResults ?? [];
const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel]));
const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified';
const deviations = results.filter(result => result.isDeviation);
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({
domainId, label: labels.get(domainId) ?? domainId,
items: deviations.filter(result => domainOf(result) === domainId)
.sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized)
|| a.title.localeCompare(b.title))
}));
const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources]));
const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item]));
const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields]));
const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']);
const policyRows = projection.policyEntries.map(item => [
plain(item.title), plain(item.scope), plain(item.identity),
`${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`,
item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein'
]);
const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]);
const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]);
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)}</title>
<style>
:root{font-family:Inter,system-ui,sans-serif;color:#20212a;background:#f1f1f4;font-size:16px}
*{box-sizing:border-box}body{max-width:1240px;margin:0 auto;padding:20px}h1{font-size:32px}h2{font-size:24px;margin:0 0 16px}
h3{font-size:18px}h4{font-size:16px;margin:0}h5{font-size:14px;margin:0 0 8px}p{line-height:1.5}
.head,.block,.finding,.source{background:#f5f5f7;padding:20px;margin:0 0 16px}.head{border-top:4px solid #e2a024}
.block{margin-top:24px}.finding,.source{background:#fff;border-left:2px solid #d6d6dc;break-inside:avoid}
.finding header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid #d6d6dc;padding-bottom:12px}
.finding header span,.meta,.limit{color:#555967;font-size:14px}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px}
.facts div{min-width:0}.facts dt{font-size:14px;color:#555967}.facts dd{margin:2px 0 0;overflow-wrap:anywhere}
.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d6d6dc;padding:8px;overflow-wrap:anywhere}
th{text-transform:uppercase;font-weight:600;font-size:14px}pre{font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
.instruction{margin:8px 0}.instruction div{display:grid;grid-template-columns:140px 1fr;gap:8px;padding:4px 0}.instruction dt{font-weight:600}.instruction dd{margin:0;overflow-wrap:anywhere}
.different{background:#fce9e9;border-left:3px solid #bd3f45;padding:2px 6px}.values{margin:0;padding-left:20px}.source-value{border-top:1px solid #d6d6dc;padding-top:8px}
@media(max-width:700px){.facts{grid-template-columns:1fr}.instruction div{grid-template-columns:1fr}}
@media print{:root{background:#fff}body{max-width:none;padding:0}.block,.head{background:#fff}.finding,.source{background:#fff}h2,h3,h4{break-after:avoid}}
</style></head><body>
<header class="head"><p>ASSESSOR · Technical Report</p><h1>${escapeHtml(projection.project.name)}</h1>
<p>Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
<p class="limit">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p></header>
<section class="block"><h2>Scope und Provenienz</h2>
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="limit">Keine Provenienz übermittelt.</p>'}</section>
<section class="block"><h2>Result-Inventar</h2><p>${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run</p>
${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))}
${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))}
${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))}
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section>
<section class="block"><h2>Findings im Detail</h2>${grouped.length ? grouped.map(group => `<section><h3>${escapeHtml(group.label)}</h3>${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}</section>`).join('') : '<p>Keine Deviations in diesem Snapshot.</p>'}</section>
<section class="block"><h2>GPO Policy Register</h2><p class="limit">Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.</p>
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [
['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass],
['Not configured · score-neutral', projection.gpo.counts['Not configured']],
['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']],
['Not comparable · score-neutral', projection.gpo.counts['Not comparable']],
['Review required · unklar', projection.gpo.counts['Review required']],
['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates]
].map(([label, count]) => [plain(label), number(Number(count))])) : ''}
${policyRows.length ? table(['Setting', 'Scope', 'Technische Identität', 'Verdict', 'Conflicts', 'Duplicates'], policyRows) : '<p class="limit">Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.</p>'}
${projection.policyEntries.map(item => `<section class="source"><h3>${plain(item.title)}</h3>${sourceSections(policySources.get(item.id) ?? [])}</section>`).join('')}</section>
<section class="block"><h2>Konvergenz-Nachweis</h2>
${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [
plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount),
plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')),
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich'
]))}
${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [
plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', '))
])) : ''}</section>
<section class="block"><h2>Scope-Grenzen</h2>
<p>GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.</p>
<p><code>not_run</code> ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.</p></section>
<section class="block"><h2>Diagnostics</h2>${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p>Keine aggregierten Diagnostics übermittelt.</p>'}</section>
<section class="block"><h2>Anhang: vollständiger Result-Index</h2>
${table(['Source Tool', 'Source Rule ID', 'Titel', 'Outcome', 'Severity', 'Security Domain'], results.map(item => [plain(item.sourceTool), plain(item.sourceRuleId), plain(item.title), plain(item.outcomeNormalized), plain(item.severityNormalized), plain(labels.get(domainOf(item)) ?? domainOf(item))]))}</section>
</body></html>`;
}

View File

@ -1,6 +1,14 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import { hasSelectedTimeslotScope } from '../../app/work-scope';
import { buildManagementOverviewHtml } from '../../exports/management-overview-report';
import { buildTechnicalReportHtml } from '../../exports/technical-report';
import { downloadText } from '../../exports/text-download';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { OperatingSystemInventory } from './OperatingSystemInventory'; import { OperatingSystemInventory } from './OperatingSystemInventory';
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import type { EnvironmentEvidenceGroup } from './environment-result-evidence';
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory'; import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
import { useApp } from '../../app/AppProvider'; import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace'; import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
@ -748,26 +756,36 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false,
* Wert entfernt, zusammengefasst oder abgeleitet; Unbekanntes bleibt der * Wert entfernt, zusammengefasst oder abgeleitet; Unbekanntes bleibt der
* Gedankenstrich der Datenschicht. * Gedankenstrich der Datenschicht.
*/ */
function DashboardFactGroup({ title, items, glyph }: { title: string; items: DashboardFact[]; glyph: DashboardGlyphName }) { function DashboardFactList({ items, id }: { items: DashboardFact[]; id?: string }) {
return <dl className="client-env-facts" id={id}>
{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>;
}
function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: {
title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
}) {
const unreported = items.length > 0 && items.every(item => item.value === UNKNOWN_FACT); const unreported = items.length > 0 && items.every(item => item.value === UNKNOWN_FACT);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const listId = useId(); const listId = useId();
return <section className="client-env-group"> return <section className="client-env-group">
<h3 className="client-env-group-title"> <header className="client-env-group-header">
<DashboardGlyph name={glyph} /> <h3 className="client-env-group-title">
<span>{title}</span> <DashboardGlyph name={glyph} />
{unreported && <> <span>{title}</span>
<span className="client-env-group-note">not reported by source</span> {unreported && <>
<button className="client-env-group-toggle" type="button" aria-expanded={expanded} aria-controls={listId} <span className="client-env-group-note">not reported by source</span>
aria-label={`${expanded ? 'Hide' : 'Show'} ${title} fields`} title={`${expanded ? 'Hide' : 'Show'} fields`} <button className="client-env-group-toggle" type="button" aria-expanded={expanded} aria-controls={listId}
onClick={() => setExpanded(value => !value)}> aria-label={`${expanded ? 'Hide' : 'Show'} ${title} fields`} title={`${expanded ? 'Hide' : 'Show'} fields`}
<TriangleIcon direction={expanded ? 'down' : 'right'} /> onClick={() => setExpanded(value => !value)}>
</button> <TriangleIcon direction={expanded ? 'down' : 'right'} />
</>} </button>
</h3> </>}
{(!unreported || expanded) && <dl className="client-env-facts" id={listId}> </h3>
{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)} <EnvironmentResultsButton title={title}><EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/></EnvironmentResultsButton>
</dl>} </header>
{(!unreported || expanded) && <DashboardFactList items={items} id={listId}/>}
</section>; </section>;
} }
@ -874,6 +892,65 @@ interface DashboardSourceScore {
rating?: SecuritySourceScore; rating?: SecuritySourceScore;
} }
function DashboardSourceResults({ sources, snapshot }: { sources: DashboardSourceScore[]; snapshot?: EnvironmentSnapshot }) {
const [visibleRecords, setVisibleRecords] = useState(100);
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
const records = sources.flatMap(source => source.records.map(record => ({ source, record })));
return <div className="client-env-evidence">
<section className="client-env-evidence-section">
<h3>Source result contributions</h3>
{sources.length ? <div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Results</th><th scope="col">Passed</th>
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Review required</th>
<th scope="col">Reevaluation required</th><th scope="col">Score</th><th scope="col">Weight</th>
<th scope="col">Scoring method</th><th scope="col">Issues</th>
</tr></thead><tbody>{sources.map(source => <tr key={source.tool.id}>
<td>{source.tool.label}</td><td>{quantity(source.records.length)}</td><td>{quantity(source.passed)}</td>
<td>{quantity(source.deviations)}</td><td>{quantity(source.assessed)}</td>
<td>{quantity(source.reviewRequired)}</td><td>{quantity(source.reevaluationRequired)}</td>
<td>{quantity(source.score)}</td><td>{quantity(source.weight)}</td>
<td>{source.rating?.methods.join(', ') || '—'}</td><td>{source.rating?.issues.join(' · ') || '—'}</td>
</tr>)}</tbody></table></div> : <p>No source results available.</p>}
</section>
{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? []).length > 0 && <section className="client-env-evidence-section">
<h3>Scan score evidence</h3>
<div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Method</th><th scope="col">Score</th>
<th scope="col">Native property</th><th scope="col">Native value</th><th scope="col">Passed</th>
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Eligible</th>
<th scope="col">Run IDs</th><th scope="col">Import file IDs</th><th scope="col">Issues</th>
</tr></thead><tbody>{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? [])
.map(({ source, scan }) => <tr key={scan.id}>
<td>{source.tool.label}</td><td>{scan.method}</td><td>{quantity(scan.score)}</td>
<td>{scan.nativeProperty ?? '—'}</td><td>{quantity(scan.nativeValue)}</td>
<td>{quantity(scan.passed)}</td><td>{quantity(scan.deviations)}</td><td>{quantity(scan.assessed)}</td>
<td>{quantity(scan.eligible)}</td><td>{scan.runIds.join(', ') || '—'}</td>
<td>{scan.importFileIds.join(', ') || '—'}</td><td>{scan.issues.join(' · ') || '—'}</td>
</tr>)}</tbody></table></div>
</section>}
{records.length > 0 && <section className="client-env-evidence-section">
<h3>Source finding records <span>{quantity(records.length)}</span></h3>
<div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Finding</th><th scope="col">Record ID</th>
<th scope="col">Original fields</th><th scope="col">Filled fields</th><th scope="col">Metadata</th>
<th scope="col">Scan run</th><th scope="col">Import file</th><th scope="col">Source sheet</th>
<th scope="col">Source file</th><th scope="col">Policy source evidence</th><th scope="col">Complete retained record</th>
</tr></thead><tbody>{records.slice(0, visibleRecords).map(({ source, record }) => <tr key={`${source.tool.id}:${record.id}`}>
<td>{source.tool.label}</td><td>{record.title}</td><td>{record.id}</td>
<td>{JSON.stringify(record.fields)}</td><td>{JSON.stringify(record.filled)}</td><td>{JSON.stringify(record.meta)}</td>
<td>{record.scanRunId ?? '—'}</td><td>{record.importFileId ?? '—'}</td>
<td>{record.sourceSheet ?? '—'}</td><td>{record.sourceFile ?? '—'}</td>
<td>{record.policySourceEvidence ? JSON.stringify(record.policySourceEvidence) : '—'}</td>
<td>{JSON.stringify(record)}</td>
</tr>)}</tbody></table></div>
{visibleRecords < records.length && <button className="btn" type="button" onClick={() => setVisibleRecords(value => value + 100)}>
Show more findings ({quantity(visibleRecords)} of {quantity(records.length)})
</button>}
</section>}
<EnvironmentEvidenceTables snapshot={snapshot} group="all"/>
</div>;
}
interface DashboardDomainScore { interface DashboardDomainScore {
id: string; id: string;
label: string; label: string;
@ -1308,6 +1385,29 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const gpoInventory = useGpoInventory(app.targetProjectId); const gpoInventory = useGpoInventory(app.targetProjectId);
const sourceFilter = app.facetFilters['provenance:source-scan']; const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter); const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
const reportScopeRef = useRef(reportScopeKey);
reportScopeRef.current = reportScopeKey;
const exportReport = async (kind: 'management' | 'technical') => {
const projectId = app.targetProjectId;
if (!projectId || !dataset || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)) return;
const requestedScope = reportScopeKey;
setReportExporting(kind);
setReportError(null);
try {
const projection = await assessorApi.getReportProjection(projectId, app.activeScanRunIds, app.activeAssessmentIds);
if (reportScopeRef.current !== requestedScope) return;
const report = kind === 'management' ? buildManagementOverviewHtml(projection) : buildTechnicalReportHtml(projection);
const stamp = projection.generatedAt.slice(0, 10);
downloadText(browserDownloadAdapter, `assessor-${kind}-report-${stamp}.html`, report, 'text/html;charset=utf-8');
} catch (reason) {
setReportError(reason instanceof Error ? reason.message : 'Report could not be generated.');
} finally {
setReportExporting(null);
}
};
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]); const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]); const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false); const [scoringOpen, setScoringOpen] = useState(false);
@ -1394,6 +1494,14 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setView('columns'); setView('columns');
onOpenWork(); onOpenWork();
}; };
const sourceOverviewContent = orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => {
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
// The source drilldown includes saved GPO review/conflict/duplicate rows.
if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
}) : undefined} />
)}</ul> : <p className="dashboard-empty">No source results available.</p>;
const selectedSource = sourceFilter?.values?.length const selectedSource = sourceFilter?.values?.length
? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length ? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length
&& tool.sourceValues.every(value => sourceFilter.values!.includes(value)))?.id ?? 'selection' && tool.sourceValues.every(value => sourceFilter.values!.includes(value)))?.id ?? 'selection'
@ -1411,6 +1519,15 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
if (targetSheet) app.setActiveSheet(targetSheet.id); if (targetSheet) app.setActiveSheet(targetSheet.id);
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null); setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label> }}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
<div className="client-report-actions" aria-label="Assessment reports">
<button className="btn" type="button" disabled={Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
{reportExporting === 'management' ? 'Creating Management Report…' : 'Management Report'}
</button>
<button className="btn" type="button" disabled={Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('technical')}>
{reportExporting === 'technical' ? 'Creating Technical Report…' : 'Technical Report'}
</button>
</div>
{reportError && <p className="client-report-error" role="alert">{reportError}</p>}
</header> </header>
<div className="client-overview-main"> <div className="client-overview-main">
@ -1503,21 +1620,23 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
</p>} </p>}
<div className="client-environment-grid"> <div className="client-environment-grid">
<section className="client-environment-sources" aria-labelledby="client-environment-sources-heading"> <section className="client-environment-sources" aria-labelledby="client-environment-sources-heading">
<header><h3 id="client-environment-sources-heading"><DashboardGlyph name="scan"/><span>Source Overview</span></h3><button className="btn" type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button></header> <header><h3 id="client-environment-sources-heading"><DashboardGlyph name="scan"/><span>Source Overview</span></h3>
{orderedSourceScores.length ? <ul>{orderedSourceScores.map(source => <DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics} onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => { <div className="client-environment-source-actions">
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues }); <button className="btn" type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button>
// A source drilldown opens its saved results, including GPO review, <EnvironmentResultsButton title="Source Overview">
// conflict and duplicate rows. Explicit deviation actions stay filtered. <DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
if (/gpo/i.test(source.tool.label)) setStatusFilter('all'); </EnvironmentResultsButton>
}) : undefined} />)}</ul> : <p className="dashboard-empty">No source results available.</p>} </div>
</header>
{sourceOverviewContent}
</section> </section>
<DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" /> <DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" /> <DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" /> <DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" /> <DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" /> <DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
<DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" /> <DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" snapshot={environment.snapshot} evidenceGroup="source-metrics" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} /> <OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />
</div> </div>
</details> </details>

View File

@ -0,0 +1,121 @@
import { useState } from 'react';
import type { JsonValue, SourcePosition } from '../../domain/canonical-model';
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { environmentEvidenceForGroup, environmentSectionTitle, environmentTableSubsection, type EnvironmentEvidenceGroup } from './environment-result-evidence';
const PAGE_SIZE = 100;
function sourcePosition(source: SourcePosition): string {
return [source.objectPath, source.elementSelector, source.cellReference,
source.rowIndex === undefined ? undefined : `row ${source.rowIndex}`,
source.columnIndex === undefined ? undefined : `column ${source.columnIndex}`]
.filter(Boolean).join(' · ') || '—';
}
function valueText(value: JsonValue | undefined): string {
if (value === undefined) return '—';
if (value === null) return 'null';
if (typeof value === 'string') return value === '' ? '""' : value;
return JSON.stringify(value);
}
function attributesText(attributes: Record<string, JsonValue>): string {
const visible = Object.fromEntries(Object.entries(attributes).filter(([key]) => key !== 'sourceTableIndex'));
return Object.keys(visible).length ? JSON.stringify(visible) : '—';
}
function EvidenceTable({ table, subsection }: { table: ScannerReportTable; subsection?: string }) {
const [visible, setVisible] = useState(PAGE_SIZE);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const nativeName = /^(?:Tabelle|Table)\s+\d+$/i.test(table.name) ? undefined : table.name;
return <section className="client-env-evidence-table">
<h5>{subsection || nativeName || 'Source table'} <span>{subsection && nativeName && nativeName !== subsection ? `${nativeName} · ` : ''}{table.rows.length.toLocaleString('en-US')} rows</span></h5>
{width ? <div className="client-env-evidence-scroll"><table>
<thead><tr>{Array.from({ length: width }, (_, index) =>
<th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}</tr></thead>
<tbody>{table.rows.slice(0, visible).map((row, rowIndex) => <tr key={rowIndex}>
{Array.from({ length: width }, (_, index) => <td key={index}>{valueText(row[index])}</td>)}
</tr>)}</tbody>
</table></div> : <p>No columns retained in this source table.</p>}
{visible < table.rows.length && <button type="button" className="btn" onClick={() => setVisible(value => value + PAGE_SIZE)}>
Show more rows ({Math.min(visible, table.rows.length).toLocaleString('en-US')} of {table.rows.length.toLocaleString('en-US')})
</button>}
</section>;
}
function EvidenceSection({ section, snapshot }: { section: ScannerReportSection; snapshot: EnvironmentSnapshot }) {
return <section className="client-env-evidence-section">
<h4>{environmentSectionTitle(snapshot, section)}</h4>
<div className="client-env-evidence-scroll"><table className="client-env-evidence-meta"><tbody>
<tr><th scope="row">Tool</th><td>{section.tool.name} · {section.tool.version ?? 'version not reported'}</td></tr>
<tr><th scope="row">Section key</th><td>{section.sectionKey}</td></tr>
<tr><th scope="row">State</th><td>{section.state}</td></tr>
<tr><th scope="row">Source</th><td>{sourcePosition(section.source)}</td></tr>
<tr><th scope="row">Scan run</th><td>{section.scanRunId}</td></tr>
<tr><th scope="row">Import file</th><td>{section.importFileId}</td></tr>
<tr><th scope="row">Extractor</th><td>{section.extractorVersion}</td></tr>
<tr><th scope="row">Attributes</th><td>{attributesText(section.attributes)}</td></tr>
<tr><th scope="row">Section ID</th><td>{section.id}</td></tr>
<tr><th scope="row">Parent / order</th><td>{section.parentSectionKey ?? '—'} · {section.ordinal}</td></tr>
</tbody></table></div>
{section.prose !== undefined && <p className="client-env-evidence-prose">{section.prose === '' ? '""' : section.prose}</p>}
{section.tables.map((table, index) => <EvidenceTable table={table} subsection={environmentTableSubsection(snapshot, section, table)}
key={`${table.sourceTableIndex ?? index}:${table.name}`}/>)}
</section>;
}
function ObservationTable({ observations }: { observations: EnvironmentObservation[] }) {
const [visible, setVisible] = useState(PAGE_SIZE);
return <section className="client-env-evidence-section">
<h4>Retained observations <span>{observations.length.toLocaleString('en-US')}</span></h4>
<div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Observation ID</th><th scope="col">Tool</th><th scope="col">Subject</th><th scope="col">Property</th>
<th scope="col">Original value</th><th scope="col">Normalized value</th><th scope="col">Value type</th><th scope="col">State</th>
<th scope="col">Section</th><th scope="col">Source</th><th scope="col">Scan run</th>
<th scope="col">Import file</th><th scope="col">Observed at</th><th scope="col">Mapping</th><th scope="col">Attributes</th>
</tr></thead><tbody>{observations.slice(0, visible).map(observation => <tr key={observation.id}>
<td>{observation.id}</td><td>{observation.tool.name} ({observation.tool.id}) · {observation.tool.version ?? 'version not reported'}</td>
<td>{[observation.subject.type, observation.subject.label, observation.subject.identity].filter(Boolean).join(' · ')}</td>
<td>{observation.label}<br/><span>{observation.property}</span></td>
<td>{valueText(observation.originalValue)}</td><td>{valueText(observation.normalizedValue)}</td>
<td>{observation.valueType}</td><td>{observation.state}</td><td>{observation.sectionKey ?? '—'}</td><td>{sourcePosition(observation.source)}</td>
<td>{observation.scanRunId}</td><td>{observation.importFileId}</td><td>{observation.observedAt ?? '—'}</td><td>{observation.mappingVersion}</td>
<td>{attributesText(observation.attributes)}</td>
</tr>)}</tbody></table></div>
{visible < observations.length && <button type="button" className="btn" onClick={() => setVisible(value => value + PAGE_SIZE)}>
Show more observations ({visible.toLocaleString('en-US')} of {observations.length.toLocaleString('en-US')})
</button>}
</section>;
}
export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) {
const [visibleSections, setVisibleSections] = useState(PAGE_SIZE);
if (!snapshot) return <p className="client-env-evidence-empty">No retained scanner context available for this selection.</p>;
const { sections, observations } = environmentEvidenceForGroup(snapshot, group);
if (!sections.length && !observations.length && group !== 'all')
return <p className="client-env-evidence-empty">No retained source facts for this category.</p>;
return <div className="client-env-evidence">
{group === 'all' && <section className="client-env-evidence-section">
<h3>Snapshot scope</h3><div className="client-env-evidence-scroll"><table className="client-env-evidence-meta"><tbody>
<tr><th scope="row">Project</th><td>{snapshot.projectId}</td></tr>
<tr><th scope="row">Assessment</th><td>{snapshot.assessmentId ?? '—'}</td></tr>
<tr><th scope="row">Scan runs</th><td>{snapshot.scanRunIds.join(', ') || '—'}</td></tr>
<tr><th scope="row">Generated at</th><td>{snapshot.generatedAt}</td></tr>
<tr><th scope="row">Observed</th><td>{snapshot.coverage.observed}</td></tr>
<tr><th scope="row">Explicitly absent</th><td>{snapshot.coverage.explicitlyAbsent}</td></tr>
<tr><th scope="row">Empty</th><td>{snapshot.coverage.empty}</td></tr>
<tr><th scope="row">Restricted</th><td>{snapshot.coverage.restricted}</td></tr>
<tr><th scope="row">Incomplete</th><td>{snapshot.coverage.incomplete}</td></tr>
<tr><th scope="row">Snapshot version</th><td>{snapshot.version}</td></tr>
</tbody></table></div>
</section>}
{group === 'all' && !sections.length && !observations.length
&& <p className="client-env-evidence-empty">No retained report sections or observations in this snapshot.</p>}
{sections.length > 0 && <div className="client-env-evidence-block"><h3>Original report sections <span>{sections.length.toLocaleString('en-US')}</span></h3>
{sections.slice(0, visibleSections).map(section => <EvidenceSection section={section} snapshot={snapshot} key={section.id}/>)}
{visibleSections < sections.length && <button type="button" className="btn" onClick={() => setVisibleSections(value => value + PAGE_SIZE)}>
Show more sections ({visibleSections.toLocaleString('en-US')} of {sections.length.toLocaleString('en-US')})
</button>}</div>}
{observations.length > 0 && <ObservationTable observations={observations}/>}
</div>;
}

View File

@ -0,0 +1,18 @@
import { useRef, useState, type ReactNode } from 'react';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
/** One interaction for every Environment Detail tile, including OS inventory. */
export function EnvironmentResultsButton({ title, children }: { title: string; children: ReactNode }) {
const [windowOpen, setWindowOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeWindow = () => {
setWindowOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
return <>
<button ref={triggerRef} className={`btn results-btn client-environment-results-button${windowOpen ? ' is-open' : ''}`}
type="button" onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen}
aria-label={`Open ${title} results`} title="Open results">Results</button>
{windowOpen && <FloatingDataWindow title={title} onClose={closeWindow}>{children}</FloatingDataWindow>}
</>;
}

View File

@ -1,5 +1,7 @@
import { useRef, useState, type ReactNode } from 'react'; import type { ReactNode } from 'react';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow'; import type { EnvironmentSnapshot } from '../../domain/environment-context';
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import type { OperatingSystemInventoryGroup } from './operating-system-inventory'; import type { OperatingSystemInventoryGroup } from './operating-system-inventory';
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US'); const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
@ -29,24 +31,18 @@ function OperatingSystemGroups({ groups, inventories }: { groups: OperatingSyste
</section>)}</div>; </section>)}</div>;
} }
export function OperatingSystemInventory({ groups, icon }: { groups: OperatingSystemInventoryGroup[]; icon?: ReactNode }) { export function OperatingSystemInventory({ groups, icon, snapshot }: {
groups: OperatingSystemInventoryGroup[]; icon?: ReactNode; snapshot?: EnvironmentSnapshot;
}) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))]; const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
const [windowOpen, setWindowOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeWindow = () => {
setWindowOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
return <section className="client-os-inventory" aria-label="Operating systems"> return <section className="client-os-inventory" aria-label="Operating systems">
<header> <header>
<h3>{icon}<span>Operating systems</span></h3> <h3>{icon}<span>Operating systems</span></h3>
<button ref={triggerRef} className={`btn results-btn client-os-results-button${windowOpen ? ' is-open' : ''}`} type="button" <EnvironmentResultsButton title="Operating systems">
onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen} <OperatingSystemGroups groups={groups} inventories={inventories} />
aria-label="Open Operating systems results" title="Open results">Results</button> <EnvironmentEvidenceTables snapshot={snapshot} group="operating-systems"/>
</EnvironmentResultsButton>
</header> </header>
<OperatingSystemGroups groups={groups} inventories={inventories} /> <OperatingSystemGroups groups={groups} inventories={inventories} />
{windowOpen && <FloatingDataWindow title="Operating systems" onClose={closeWindow}>
<OperatingSystemGroups groups={groups} inventories={inventories} />
</FloatingDataWindow>}
</section>; </section>;
} }

View File

@ -0,0 +1,117 @@
import type { AssessmentProject } from '../../domain/canonical-model';
import type { ImportedDataset } from '../../domain/dataset';
import type { EnvironmentSnapshot } from '../../domain/environment-context';
import type { FindingRecord } from '../../domain/finding';
import type { Review } from '../../domain/review';
import {
buildReportProjection, reportPriorityBases,
type ReportPolicyEntry, type ReportProjection, type ReportProjectionInput, type ReportScoreSource
} from '../../domain/report-projection';
import type { UnifiedResultProjection } from '../../domain/unified-result';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { securityDomainCatalog, securityDomainIdForUnifiedResult, unclassifiedSecurityDomain } from './security-domains';
function field(record: FindingRecord, label: string): string {
return record.fields.find(item => item.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'))?.value?.trim() ?? '';
}
function policyEntry(record: FindingRecord): ReportPolicyEntry | undefined {
if (field(record, 'Category') !== 'GPOs') return undefined;
const status = field(record, 'Status');
const verdict = status === 'Deviation' ? 'Fail' : status === 'Passed' ? 'Pass' : status;
if (!['Fail', 'Pass', 'Not configured', 'No baseline rule', 'Not comparable', 'Review required'].includes(verdict)) return undefined;
return {
id: record.id,
verdict: verdict as ReportPolicyEntry['verdict'],
conflict: Boolean(field(record, 'Conflict')),
duplicate: Boolean(field(record, 'Duplicates')),
title: record.title,
scope: field(record, 'Scope'),
identity: field(record, 'Technical Identity'),
backupInstanceIds: [...new Set((record.policySourceEvidence ?? []).flatMap(source => source.backupInstanceId ? [source.backupInstanceId] : []))]
};
}
export interface ClientReportProjectionInput {
project: AssessmentProject;
assessmentIds?: string[];
generatedAt: string;
results: UnifiedResultProjection[];
dataset: ImportedDataset;
environment?: EnvironmentSnapshot;
overallScore?: number;
scoreSources?: ReportScoreSource[];
reviews?: Record<string, Review>;
}
/** One frozen, filter-independent cut for both HTML files. */
export function clientReportInput(input: ClientReportProjectionInput): ReportProjectionInput {
const projectSheet = input.dataset.sheets.find(sheet => sheet.isProjectSheet);
const policyRecords = (projectSheet?.analysis.records ?? []).filter(record => field(record, 'Category') === 'GPOs');
const policyEntries = policyRecords.flatMap(record => {
const entry = policyEntry(record);
return entry ? [entry] : [];
});
const workRecordByOccurrence = new Map((projectSheet?.analysis.records ?? [])
.flatMap(record => record.findingOccurrenceId ? [[record.findingOccurrenceId, record] as const] : []));
const reviewInputs = input.results.flatMap(result => {
const record = workRecordByOccurrence.get(result.sourceResultId);
const review = record && input.reviews?.[record.id];
if (!review) return [];
return [{
resultProjectionId: result.resultProjectionId,
include: review.include, status: review.status, priority: review.priority,
owner: review.owner || undefined, due: review.due || undefined,
prerequisites: review.prerequisites || undefined, access: review.access || undefined,
nextStep: review.nextStep || undefined,
markers: [review.markerQuickwin && 'quickwin', review.markerBlocked && 'blocked',
review.markerExternal && 'external', review.markerQuestion && 'question',
review.markerNotRelevant && 'notRelevant'].filter((value): value is string => Boolean(value)),
riskAccepted: Boolean(review.riskAcceptance)
}];
});
const environment = [
['Domain', 'domain.fqdn'], ['Forest', 'forest.name'], ['Domain Controller', 'domain.controller_count']
].map(([key, property]) => ({
key,
value: input.environment?.observations.find(item => item.property === property && item.state === 'observed')?.originalValue?.toString()
}));
const provenance = input.results.flatMap(result => [
{ kind: 'ScanRun', id: result.scanRunId ?? result.collectionRunId, observedAt: result.sourceObservedAt },
...(result.importArtifactId ? [{ kind: 'ImportArtifact', id: result.importArtifactId }] : []),
...(result.evaluationRunId ? [{ kind: 'EvaluationRun', id: result.evaluationRunId }] : []),
...(result.sourceProfileVersion ? [{ kind: `${result.sourceTool ?? 'Source'} profile`, id: result.sourceProfileId ?? 'unknown', version: result.sourceProfileVersion }] : []),
...(result.mappingRuleVersion ? [{ kind: 'Mapping', id: result.sourceTool ?? 'unknown', version: result.mappingRuleVersion }] : [])
]);
const uniqueProvenance = [...new Map(provenance.map(item => [`${item.kind}\u0000${item.id}\u0000${item.version ?? ''}`, item])).values()];
return {
generatedAt: input.generatedAt,
project: { id: input.project.id, name: input.project.name },
assessmentId: input.assessmentIds?.length === 1 ? input.assessmentIds[0] : undefined,
results: input.results,
domains: [
...securityDomainCatalog.map(({ id, label }) => ({ id, label })),
...(input.results.some(result => securityDomainIdForUnifiedResult(result) === 'unclassified')
? [unclassifiedSecurityDomain] : [])
],
domainOf: securityDomainIdForUnifiedResult,
priorityBases: reportPriorityBases(toolProfileDefinitions),
overallScore: input.overallScore,
scoreSources: input.scoreSources,
environment,
policyEntries,
reviews: reviewInputs,
technicalPolicySources: policyRecords.map(record => ({ settingId: record.id, sources: record.policySourceEvidence ?? [] })),
technicalFindingFields: input.results.flatMap(result => {
const record = workRecordByOccurrence.get(result.sourceResultId);
return record ? [{ resultProjectionId: result.resultProjectionId, fields: record.fields }] : [];
}),
policyScopeLimited: true,
provenance: uniqueProvenance,
priorityLimitPerDomain: 8
};
}
export function buildClientReportProjection(input: ClientReportProjectionInput): ReportProjection {
return buildReportProjection(clientReportInput(input));
}

View File

@ -0,0 +1,116 @@
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
export type EnvironmentEvidenceGroup = 'all' | 'directory' | 'resilience' | 'inventory'
| 'password-policy' | 'certificates' | 'source-metrics' | 'operating-systems';
export interface EnvironmentEvidence {
sections: ScannerReportSection[];
observations: EnvironmentObservation[];
}
interface SubsectionLabels {
explicit: Set<string>;
rowCount: Set<string>;
}
const subsectionCache = new WeakMap<EnvironmentSnapshot, Map<string, SubsectionLabels>>();
const subsectionKey = (runId: string, fileId: string, sectionKey: string, tableIndex: number) =>
`${runId}\u0000${fileId}\u0000${sectionKey}\u0000${tableIndex}`;
function indexedSubsections(snapshot: EnvironmentSnapshot): Map<string, SubsectionLabels> {
const cached = subsectionCache.get(snapshot);
if (cached) return cached;
const index = new Map<string, SubsectionLabels>();
for (const observation of snapshot.observations) {
const tableIndex = observation.attributes.sourceTableIndex;
if (typeof tableIndex !== 'number' || !observation.sectionKey) continue;
const key = subsectionKey(observation.scanRunId, observation.importFileId, observation.sectionKey, tableIndex);
let labels = index.get(key);
if (!labels) { labels = { explicit: new Set(), rowCount: new Set() }; index.set(key, labels); }
const explicit = observation.attributes.subsectionLabel;
if (typeof explicit === 'string' && explicit.trim()) labels.explicit.add(explicit.trim());
if (/\.row_count$/.test(observation.property) && observation.subject.label?.trim())
labels.rowCount.add(observation.subject.label.trim());
}
subsectionCache.set(snapshot, index);
return index;
}
/** Retained native subsection headings are keyed by source table within one exact report section. */
export function environmentTableSubsection(
snapshot: EnvironmentSnapshot, section: ScannerReportSection, table: ScannerReportTable
): string | undefined {
if (table.sourceTableIndex === undefined) return undefined;
const labels = indexedSubsections(snapshot).get(subsectionKey(
section.scanRunId, section.importFileId, section.sectionKey, table.sourceTableIndex));
if (!labels) return undefined;
if (labels.explicit.size) return labels.explicit.size === 1 ? [...labels.explicit][0] : undefined;
return labels.rowCount.size === 1 ? [...labels.rowCount][0] : undefined;
}
/** A section labelled with its first subsection must not appear to own all later siblings. */
export function environmentSectionTitle(snapshot: EnvironmentSnapshot, section: ScannerReportSection): string {
const subsectionLabels = new Set(section.tables
.map(table => environmentTableSubsection(snapshot, section, table)).filter((label): label is string => Boolean(label)));
if (subsectionLabels.size > 1 && subsectionLabels.has(section.label)) {
const fromKey = section.sectionKey.replace(/^section/i, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
if (fromKey && fromKey.toLocaleLowerCase('en-US') !== section.label.toLocaleLowerCase('en-US')) return fromKey;
}
return section.label || section.sectionKey;
}
const osTable = (table: ScannerReportTable) => /operating\s+systems?/i.test(table.name);
const resilienceProperty = /^(?:backup\.|laps\.|kerberos\.krbtgt\.|logging\.wef\.|domain\.recycle_bin_enabled$)/i;
const inventorySection = /^section(?:UserInformation|ComputerInformation|AdminGroups|Trustsdetails|ControlPathsAnalysis|neverexpiresuser|enabledtrusteddelegationcomputer)$/i;
function observationInGroup(observation: EnvironmentObservation, group: EnvironmentEvidenceGroup): boolean {
const property = observation.property;
const section = observation.sectionKey ?? '';
if (group === 'operating-systems') return /^operating_system\./i.test(property)
|| /operating\s+systems?/i.test(String(observation.attributes.tableName ?? ''));
if (group === 'resilience') return resilienceProperty.test(property);
if (group === 'password-policy') return /^password_policy\./i.test(property) || /^sectionPasswordPolicies$/i.test(section);
if (group === 'certificates') return /^pki\./i.test(property) || /^sectionPKI$/i.test(section);
if (group === 'directory') return /^(?:domain\.|forest\.|entra\.)/i.test(property)
|| /^section(?:DomainInformation|Infrastructure)$/i.test(section);
if (group === 'inventory') return /^(?:host\.|trust\.|user\.|computer\.|domain\.controller_count$)/i.test(property)
|| inventorySection.test(section);
if (group === 'source-metrics') return /^(?:report\.|risk\.|purple_knight\.|benchmark\.|maester\.|entra\.tenant_(?:id|name)$)/i.test(property)
|| /^(?:report-overview|purple-knight-assessment-summary)$/i.test(section);
return true;
}
function sectionForGroup(section: ScannerReportSection, group: EnvironmentEvidenceGroup): ScannerReportSection | undefined {
if (group === 'all') return section;
const key = section.sectionKey;
if (group === 'directory' && /^section(?:DomainInformation|Infrastructure)$/i.test(key)) return section;
if (group === 'inventory' && inventorySection.test(key)) return section;
if (group === 'password-policy' && /^sectionPasswordPolicies$/i.test(key)) return section;
if (group === 'certificates' && /^sectionPKI$/i.test(key)) return section;
if (group === 'source-metrics' && /^(?:report-overview|purple-knight-assessment-summary)$/i.test(key)) return section;
if (group === 'operating-systems' && /^sectionComputerInformation$/i.test(key)) {
const tables = section.tables.filter(osTable);
return tables.length ? { ...section, tables, prose: undefined } : undefined;
}
if (group === 'resilience' && /^sectionAnomalies$/i.test(key)) {
const tables = section.tables.filter(table => /backup|laps|krbtgt|event\s+forwarding|\bwef\b|recycle/i.test(table.name));
return tables.length ? { ...section, tables, prose: undefined } : undefined;
}
return undefined;
}
/** Read-only, source-backed projection. Every retained row remains available in `all`. */
export function environmentEvidenceForGroup(
snapshot: EnvironmentSnapshot | undefined,
group: EnvironmentEvidenceGroup
): EnvironmentEvidence {
if (!snapshot) return { sections: [], observations: [] };
return {
sections: snapshot.sections.flatMap(section => {
const selected = sectionForGroup(section, group);
return selected ? [selected] : [];
}),
observations: group === 'all' ? snapshot.observations
: snapshot.observations.filter(observation => observationInGroup(observation, group))
};
}

View File

@ -1,4 +1,5 @@
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import type { UnifiedResultProjection } from '../../domain/unified-result';
export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain'; export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain';
@ -68,6 +69,15 @@ export function securityDomainIdForRecord(record: FindingRecord): string {
return unclassifiedSecurityDomain.id; return unclassifiedSecurityDomain.id;
} }
/** Unified-result counterpart of the Work/Client Info catalog assignment. */
export function securityDomainIdForUnifiedResult(result: UnifiedResultProjection): string {
if (result.sourceKind === 'policy-evaluation' || result.sourceKind === 'policy-observation'
|| result.categories.some(category => /^gpos?$/i.test(category.categoryLabel.trim()))) return 'gpo';
const evidence = [result.title, result.description,
...result.categories.map(category => category.categoryLabel)].filter(Boolean).join(' ');
return securityDomainCatalog.find(domain => domain.pattern.test(evidence))?.id ?? unclassifiedSecurityDomain.id;
}
/** Assign every record once so Client Info distributions reconcile to the total. */ /** Assign every record once so Client Info distributions reconcile to the total. */
export function dashboardDomainAssignment(records: FindingRecord[]): Map<string, FindingRecord[]> { export function dashboardDomainAssignment(records: FindingRecord[]): Map<string, FindingRecord[]> {
const buckets = new Map<string, FindingRecord[]>([ const buckets = new Map<string, FindingRecord[]>([

View File

@ -0,0 +1,140 @@
import type { AssessorRepository } from '../../persistence/assessor-repository';
import type { ReportDiagnostic, ReportProjectionInput, ReportProvenanceEntry, ReportScoreSource } from '../../domain/report-projection';
import { loadCanonicalProjectWorkspace } from '../project-import/load-canonical-project-workspace';
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
import { hydrateWorkspaceReviews } from '../review-workspace/database-review';
import { dashboardScoreFromStates, type DashboardResultState } from '../dashboard/dashboard-result-state';
import { securitySourceScores, weightedSecurityScore } from '../../domain/security-scoring';
import { securityToolId } from '../../scoring/canonical-security-score';
import { clientReportInput } from '../dashboard/client-report-projection';
export interface ReportAssemblyScope {
projectId: string;
assessmentId?: string;
assessmentIds?: string[];
scanRunIds?: string[];
generatedAt: string;
}
/** Repository-only, export-time assembly. No renderer derives a verdict or reads storage. */
export async function assembleReportProjectionInput(
repository: AssessorRepository,
scope: ReportAssemblyScope
): Promise<ReportProjectionInput> {
const project = await repository.getProject(scope.projectId);
if (!project) throw new Error('Project not found.');
const assessmentIds = scope.assessmentIds ?? (scope.assessmentId ? [scope.assessmentId] : undefined);
const workspace = await loadCanonicalProjectWorkspace({
listProjectAssessments: projectId => repository.listAssessments(projectId),
loadAssessmentModel: async assessmentId => {
const model = await repository.loadAssessmentModel(assessmentId);
if (!model) throw new Error(`Assessment ${assessmentId} not found.`);
return model;
},
queryUnifiedResults: query => repository.queryUnifiedResults(query),
listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }),
listMicrosoftSecurityBaselineAnalyses: async baselineId => ({
records: await repository.listMicrosoftSecurityBaselineAnalyses(baselineId)
})
}, scope.projectId, scope.scanRunIds, assessmentIds);
if (!workspace.dataset) throw new Error('No reportable assessment data in the selected scope.');
const [results, snapshot, storedReviews, processing, analyses, aggregations, evaluations] = await Promise.all([
queryAllUnifiedResults(query => repository.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId: scope.projectId,
assessmentIds: workspace.assessmentIds, scanRunIds: scope.scanRunIds,
sourceKind: ['scanner-finding', 'policy-evaluation']
}),
repository.getEnvironmentSnapshot({ projectId: scope.projectId, assessmentId: scope.assessmentId, scanRunIds: scope.scanRunIds }),
repository.listReviews(scope.projectId),
repository.listProjectProcessingRuns(scope.projectId),
repository.listGpoPolicyAnalyses(scope.projectId),
repository.listPolicySnapshotAggregations(scope.projectId),
repository.listPolicyEvaluations(scope.projectId)
]);
const dataset = workspace.dataset;
const selectedObservationIds = new Set(results.filter(result => result.sourceKind === 'policy-observation')
.map(result => result.sourceResultId));
const scopedAnalyses = analyses.map(record => ({
...record, resolutions: record.resolutions.filter(item => selectedObservationIds.has(item.observationId))
})).filter(record => record.resolutions.length > 0);
const selectedEvaluationRunIds = new Set(results.flatMap(result => result.evaluationRunId ? [result.evaluationRunId] : []));
const scopedEvaluations = evaluations.filter(record => selectedEvaluationRunIds.has(record.run.id)
|| (selectedObservationIds.size > 0 && record.results.length > 0
&& record.results.some(result => result.observationIds.some(id => selectedObservationIds.has(id)))
&& record.results.every(result => result.observationIds.every(id => selectedObservationIds.has(id)))));
const selectedAggregationRunIds = new Set(scopedEvaluations.map(record => record.run.aggregationRunId));
const scopedAggregations = aggregations.filter(record => selectedAggregationRunIds.has(record.run.id));
const selectedArtifactIds = new Set(results.flatMap(result => result.importArtifactId ? [result.importArtifactId] : []));
const scopedProcessing = processing.filter(run => selectedArtifactIds.has(run.artifactId));
const reviews = hydrateWorkspaceReviews(dataset, storedReviews, {}).reviews;
const preliminary = clientReportInput({
project, assessmentIds: workspace.assessmentIds, generatedAt: scope.generatedAt,
results, dataset, environment: snapshot, reviews
});
const ratingByTool = new Map(securitySourceScores(dataset.securityScoring?.scans ?? []).map(item => [item.toolId, item]));
const resultsByTool = new Map<string, typeof results>();
results.forEach(result => {
const toolId = securityToolId(result.sourceTool ?? 'unknown');
const group = resultsByTool.get(toolId) ?? [];
group.push(result);
resultsByTool.set(toolId, group);
});
for (const toolId of ratingByTool.keys()) if (!resultsByTool.has(toolId)) resultsByTool.set(toolId, []);
const scoreSources: ReportScoreSource[] = [...resultsByTool].map(([toolId, items]) => {
const rating = ratingByTool.get(toolId);
const states: DashboardResultState[] = toolId === 'group-policy'
? (preliminary.policyEntries ?? []).map(entry => entry.verdict === 'Pass' ? 'passed'
: entry.verdict === 'Fail' ? 'deviation' : 'unknown')
: items.map(item => item.outcomeNormalized === 'pass' ? 'passed'
: item.isDeviation ? 'deviation'
: item.outcomeNormalized === 'not_run' ? 'not-run'
: item.outcomeNormalized === 'not_applicable' ? 'not-applicable' : 'unknown');
const counted = dashboardScoreFromStates(states);
return {
toolId, score: rating?.score ?? (rating ? undefined : counted.score),
method: rating?.methods.length === 1 ? rating.methods[0]
: rating ? 'unavailable' : counted.assessed ? 'pass-rate' : 'unavailable',
weight: rating?.weight ?? dataset.securityScoring?.policy?.weights[toolId]
?? dataset.securityScoring?.policy?.defaultWeight ?? 1,
nativeValue: rating?.scans.length === 1 ? rating.scans[0].nativeValue : undefined,
nativeProperty: rating?.scans.length === 1 ? rating.scans[0].nativeProperty : undefined,
passed: counted.passed, deviations: counted.deviations, assessed: counted.assessed,
observedAt: rating?.scans.map(scan => scan.observedAt).filter((date): date is string => Boolean(date)).sort().at(-1),
issues: rating?.issues ?? []
};
});
const input = { ...preliminary, scoreSources, overallScore: weightedSecurityScore(scoreSources) };
const diagnosticMap = new Map<string, ReportDiagnostic>();
const addDiagnostic = (code: string, origin: ReportDiagnostic['origin'], reason?: string) => {
const key = `${origin}\u0000${code}\u0000${reason ?? ''}`;
const previous = diagnosticMap.get(key);
diagnosticMap.set(key, { code, origin, count: (previous?.count ?? 0) + 1, reason });
};
workspace.models.forEach(model => model.diagnostics.forEach(item => addDiagnostic(item.code, 'import', item.message)));
scopedProcessing.forEach(run => run.diagnosticCodes.forEach(code => addDiagnostic(code, 'import')));
const seenResolverObservations = new Set<string>();
scopedAnalyses.forEach(record => record.resolutions.forEach(item => {
if (item.resolution.status !== 'unresolved' || seenResolverObservations.has(item.observationId)) return;
seenResolverObservations.add(item.observationId);
addDiagnostic(item.resolution.reasonCode, 'analysis', item.resolution.reasonCode);
}));
scopedAggregations.forEach(record => record.run.diagnosticCodes.forEach(code => addDiagnostic(code, 'aggregation')));
scopedEvaluations.forEach(record => record.run.diagnosticCodes.forEach(code => addDiagnostic(code, 'evaluation')));
const provenance: ReportProvenanceEntry[] = [
...input.provenance ?? [],
...workspace.models.flatMap(model => model.importFiles.map(file => ({
kind: `ImportFile: ${file.fileName}`, id: file.id,
version: [file.processedWith.profileVersion, file.processedWith.mappingVersion,
file.processedWith.normalizationVersion].filter(Boolean).join(' / '), observedAt: file.importedAt
}))),
...scopedProcessing.map(run => ({ kind: `ProcessingRun: ${run.status}`, id: run.id,
version: run.processors.map(item => `${item.id}@${item.version}`).join(', '), observedAt: run.completedAt })),
...scopedAnalyses.map(record => ({ kind: `PolicyAnalysisRun: ${record.run.status}`, id: record.run.id,
version: `${record.run.analyzer.id}@${record.run.analyzer.version}; ${record.run.identityResolver.id}@${record.run.identityResolver.version}`,
observedAt: record.run.completedAt })),
...scopedAggregations.map(record => ({ kind: 'AggregationRun', id: record.run.id, observedAt: record.run.completedAt })),
...scopedEvaluations.map(record => ({ kind: `EvaluationRun: ${record.run.status}`, id: record.run.id,
version: record.run.evaluator.version, observedAt: record.run.completedAt }))
];
return { ...input, diagnostics: [...diagnosticMap.values()], provenance };
}

View File

@ -373,6 +373,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-environment-grid > .client-env-group, .client-environment-grid > .client-env-group,
.client-environment-grid > .client-os-inventory { min-width: 0; min-height: 0; height: 100%; padding: var(--tile-inset); overflow: auto; border: 0; border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); scrollbar-color: var(--border-strong) var(--surface-input); scrollbar-gutter: stable; scrollbar-width: thin; } .client-environment-grid > .client-os-inventory { min-width: 0; min-height: 0; height: 100%; padding: var(--tile-inset); overflow: auto; border: 0; border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); scrollbar-color: var(--border-strong) var(--surface-input); scrollbar-gutter: stable; scrollbar-width: thin; }
.client-env-group { min-width: 0; } .client-env-group { min-width: 0; }
.client-env-group-header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
.client-env-group-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin: 0; color: var(--client-info-muted); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; } .client-env-group-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin: 0; color: var(--client-info-muted); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; }
.client-env-group-title > .client-glyph { order: 0; flex: 0 0 auto; width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); } .client-env-group-title > .client-glyph { order: 0; flex: 0 0 auto; width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); }
.client-env-group-title > span:first-of-type { order: 0; flex: 0 0 auto; color: var(--client-info-text); } .client-env-group-title > span:first-of-type { order: 0; flex: 0 0 auto; color: var(--client-info-text); }
@ -392,7 +393,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-command-center .client-os-inventory > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; } .client-command-center .client-os-inventory > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; }
.client-command-center .client-os-inventory > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); } .client-command-center .client-os-inventory > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); }
.client-command-center .client-os-inventory header p { margin: 0; font-size: var(--type-body-size); color: var(--client-info-muted); } .client-command-center .client-os-inventory header p { margin: 0; font-size: var(--type-body-size); color: var(--client-info-muted); }
.client-os-results-button { flex: 0 0 auto; margin-inline-start: auto; } .client-environment-results-button { flex: 0 0 auto; margin-inline-start: auto; }
.client-command-center .client-os-groups { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--space-5); } .client-command-center .client-os-groups { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--space-5); }
.client-command-center .client-os-group { min-width: 0; } .client-command-center .client-os-group { min-width: 0; }
.client-command-center .client-os-group h4 { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); margin: 0 0 var(--space-1); color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; } .client-command-center .client-os-group h4 { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); margin: 0 0 var(--space-1); color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; }
@ -421,6 +422,24 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.floating-data-window .client-os-group table { width: 100%; max-width: 100%; } .floating-data-window .client-os-group table { width: 100%; max-width: 100%; }
.floating-data-window .client-os-group th:first-child, .floating-data-window .client-os-group th:first-child,
.floating-data-window .client-os-group td:first-child { white-space: nowrap; } .floating-data-window .client-os-group td:first-child { white-space: nowrap; }
.client-env-evidence { display: grid; align-content: start; gap: var(--space-6); min-width: 0; }
.client-env-evidence-block, .client-env-evidence-section, .client-env-evidence-table { display: grid; align-content: start; gap: var(--space-3); min-width: 0; }
.client-env-evidence-block, .client-env-evidence-section { padding-bottom: var(--space-5); border-bottom: var(--border-hair) solid var(--line); }
.client-env-evidence h3, .client-env-evidence h4, .client-env-evidence h5 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
.client-env-evidence h3 { font-size: var(--type-h3-size); }
.client-env-evidence h4, .client-env-evidence h5 { font-size: var(--type-h4-size); }
.client-env-evidence h3 span, .client-env-evidence h4 span, .client-env-evidence h5 span { margin-inline-start: var(--space-2); color: var(--muted); font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
.client-env-evidence p, .client-env-evidence-empty { margin: 0; font-size: var(--type-body-size); line-height: 1.45; overflow-wrap: anywhere; }
.client-env-evidence-scroll { max-width: 100%; overflow-x: auto; scrollbar-color: var(--border-strong) var(--surface-input); }
.client-env-evidence table { min-width: 100%; border-collapse: collapse; font-size: var(--type-table-body-size); line-height: 1.4; }
.client-env-evidence th, .client-env-evidence td { min-width: var(--table-col-min-w); padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
.client-env-evidence th { color: var(--heading); font-weight: var(--fw-semibold); white-space: nowrap; background: var(--panel-raised); }
.client-env-evidence td { color: var(--text); }
.client-env-evidence td span { color: var(--muted); }
.client-env-evidence .client-env-evidence-meta { width: 100%; table-layout: fixed; }
.client-env-evidence-meta th { width: 35%; min-width: 0; white-space: normal; }
.client-env-evidence-meta td { width: 65%; min-width: 0; }
.client-env-evidence .btn { justify-self: start; }
@media (max-width: 620px) { @media (max-width: 620px) {
.floating-data-window { min-width: 0; min-height: 0; } .floating-data-window { min-width: 0; min-height: 0; }
@ -492,6 +511,8 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var
.client-overview-source { justify-self: end; } .client-overview-source { justify-self: end; }
.client-overview-source select, .client-overview-source select,
.client-overview-assessment select { min-width: 132px; min-height: var(--control-h); padding-inline: var(--space-3); border: var(--border-hair) solid var(--client-info-line); border-radius: var(--btn-radius); color: var(--client-info-text); background: var(--client-info-surface); font: inherit; } .client-overview-assessment select { min-width: 132px; min-height: var(--control-h); padding-inline: var(--space-3); border: var(--border-hair) solid var(--client-info-line); border-radius: var(--btn-radius); color: var(--client-info-text); background: var(--client-info-surface); font: inherit; }
.client-report-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-2); }
.client-report-error { grid-column: 1 / -1; margin: 0; color: var(--sev-critical); font-size: var(--type-table-body-size); }
.client-overview-main { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; gap: var(--segment-gap); min-width: 0; } .client-overview-main { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; gap: var(--segment-gap); min-width: 0; }
.client-posture { position: relative; display: grid; grid-template-columns: minmax(300px, .82fr) minmax(430px, 1.18fr); gap: var(--segment-gap); min-width: 0; border-radius: var(--tile-radius); background: var(--tile-surface); } .client-posture { position: relative; display: grid; grid-template-columns: minmax(300px, .82fr) minmax(430px, 1.18fr); gap: var(--segment-gap); min-width: 0; border-radius: var(--tile-radius); background: var(--tile-surface); }
@ -605,7 +626,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.client-env-restricted-toggle .triangle-icon { width: var(--space-3); height: var(--space-3); } .client-env-restricted-toggle .triangle-icon { width: var(--space-3); height: var(--space-3); }
.client-environment-restricted { margin: var(--space-2) var(--tile-inset) 0; color: var(--client-info-muted); font-size: var(--type-meta-size); line-height: 1.35; } .client-environment-restricted { margin: var(--space-2) var(--tile-inset) 0; color: var(--client-info-muted); font-size: var(--type-meta-size); line-height: 1.35; }
.client-environment-sources { margin: 0; } .client-environment-sources { margin: 0; }
.client-environment-sources > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .client-environment-sources > header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); }
.client-environment-source-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin-inline-start: auto; }
.client-environment-sources > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.3; } .client-environment-sources > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.3; }
.client-environment-sources > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); } .client-environment-sources > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); }
.client-environment-sources ul { display: grid; margin: var(--space-3) 0 var(--space-4); padding: 0; border-top: var(--border-hair) solid var(--client-info-line); list-style: none; } .client-environment-sources ul { display: grid; margin: var(--space-3) 0 var(--space-4); padding: 0; border-top: var(--border-hair) solid var(--client-info-line); list-style: none; }
@ -692,6 +714,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
@container (max-width: 760px) { @container (max-width: 760px) {
.client-overview-header { grid-template-columns: 1fr; gap: var(--space-3); } .client-overview-header { grid-template-columns: 1fr; gap: var(--space-3); }
.client-overview-source { justify-self: start; } .client-overview-source { justify-self: start; }
.client-report-actions { justify-content: flex-start; }
.client-posture { grid-template-columns: 1fr; } .client-posture { grid-template-columns: 1fr; }
.client-posture-connectors { display: none; } .client-posture-connectors { display: none; }
.client-priority { grid-template-columns: 1fr; grid-template-rows: none; } .client-priority { grid-template-columns: 1fr; grid-template-rows: none; }

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => { describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => { it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.5.12'); expect(ASSESSOR_PRODUCT_VERSION).toBe('2.6.0');
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain('aria-label="About ASSESSOR"');
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { correlateReportResults } from '../../src/domain/report-correlation';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
function finding(id: string, tool: string, domain: string, refs: string[]): UnifiedResultProjection {
return {
resultProjectionId: id, sourceTool: tool, title: domain, isDeviation: true,
outcomeNormalized: 'fail', frameworkReferences: refs.map(value => ({ namespace: tool, value }))
} as unknown as UnifiedResultProjection;
}
describe('report correlation without fuzzy matching', () => {
it('distinguishes shared references from domain-only proximity and does not invent a score', () => {
const results = [finding('a', 'alpha', 'identity', ['T1110']), finding('b', 'beta', 'identity', ['T1110']),
finding('c', 'gamma', 'hardening', ['T1234'])];
const output = correlateReportResults(results, [{ id: 'identity', label: 'Identity' }, { id: 'hardening', label: 'Hardening' }],
result => result.title, [{ id: 'p1', verdict: 'Fail', conflict: false, duplicate: false,
title: 'Dummy policy', scope: 'computer', identity: 'dummy', backupInstanceIds: ['backup-1'] },
{ id: 'p2', verdict: 'Fail', conflict: true, duplicate: false,
title: 'Conflicting policy', scope: 'computer', identity: 'other', backupInstanceIds: ['backup-2'] }]);
expect(output.convergence.find(item => item.domainId === 'identity')).toMatchObject({
sourceCount: 2, evidence: 'shared-framework-reference'
});
expect(output.convergence.find(item => item.domainId === 'hardening')).toMatchObject({
sourceCount: 1, evidence: 'domain-only'
});
expect(output.bundles).toEqual([{ key: 'T1110', namespace: 'alpha, beta', members: ['a', 'b'],
toolIds: ['alpha', 'beta'], domainIds: ['identity'] }]);
expect(output.singleSourcePolicyActions).toEqual([{ policyEntryId: 'p1', backupInstanceId: 'backup-1' }]);
expect(JSON.stringify(output)).not.toMatch(/score|rank|quickwin/i);
});
});

View File

@ -18,6 +18,7 @@ function unsafeScanner(): UnifiedResultProjection {
reviewStatus: 'open', reviewOwner: 'Jane Doe', reviewDueDate: '2026-10-01', reviewStatus: 'open', reviewOwner: 'Jane Doe', reviewDueDate: '2026-10-01',
sourceObservedAt: '2026-09-22T08:00:00.000Z', importedAt: '2026-09-22T08:00:00.000Z', sourceObservedAt: '2026-09-22T08:00:00.000Z', importedAt: '2026-09-22T08:00:00.000Z',
createdAt: '2026-09-22T08:00:00.000Z', createdAt: '2026-09-22T08:00:00.000Z',
frameworkReferences: [],
categories: [{ namespace: 'private-company', categoryCode: 'private', categoryLabel: 'Private Company', categories: [{ namespace: 'private-company', categoryCode: 'private', categoryLabel: 'Private Company',
categoryType: 'source', isPrimary: true }] categoryType: 'source', isPrimary: true }]
}; };

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { FindingDefinition, FindingOccurrence, ImportFileRecord } from '../../src/domain/canonical-model';
import { scannerResultCategories, scannerResultFrameworkReferences } from '../../src/domain/unified-result';
describe('retained framework references', () => {
it('projects exact mapped values separately from categories without changing source mappings', () => {
const occurrence = {
sourceIdentity: { tool: { id: 'dummy-scanner' } },
originalFields: [{ key: 'attack', label: 'MITRE ATT&CK', value: 'T1110' },
{ key: 'defend', label: 'MITRE D3FEND', value: 'D3-XYZ' }],
fieldMappings: [
{ targetRole: 'framework-reference', sourceFieldKeys: ['attack'], normalizedValue: 'T1110' },
{ targetRole: 'framework-reference', sourceFieldKeys: ['defend'], normalizedValue: 'D3-XYZ' },
{ targetRole: 'category', sourceFieldKeys: ['class'], normalizedValue: 'Identity' }
]
} as unknown as FindingOccurrence;
const references = scannerResultFrameworkReferences(occurrence);
expect(references).toEqual([
{ namespace: 'dummy-scanner', value: 'T1110', sourceFieldKey: 'attack', sourceLabel: 'MITRE ATT&CK' },
{ namespace: 'dummy-scanner', value: 'D3-XYZ', sourceFieldKey: 'defend', sourceLabel: 'MITRE D3FEND' }
]);
const categories = scannerResultCategories({} as FindingDefinition, occurrence, {
processedWith: { profileId: 'dummy-scanner', profileVersion: '1.0.0' }
} as ImportFileRecord);
expect(categories.map(item => item.categoryLabel)).toEqual(['Identity']);
expect(occurrence.fieldMappings.map(item => item.normalizedValue)).toEqual(['T1110', 'D3-XYZ', 'Identity']);
});
});

View File

@ -4,7 +4,7 @@ import type { ReportProjection } from '../../src/domain/report-projection';
function projection(overrides: Partial<ReportProjection> = {}): ReportProjection { function projection(overrides: Partial<ReportProjection> = {}): ReportProjection {
return { return {
version: '1.0.0', version: '1.1.0',
generatedAt: '2026-09-18T10:00:00.000Z', generatedAt: '2026-09-18T10:00:00.000Z',
project: { id: 'synthetic-project', name: 'Synthetic GmbH' }, project: { id: 'synthetic-project', name: 'Synthetic GmbH' },
assessmentId: 'synthetic-assessment', assessmentId: 'synthetic-assessment',
@ -22,6 +22,9 @@ function projection(overrides: Partial<ReportProjection> = {}): ReportProjection
policyEntries: [], policyEntries: [],
workItems: [], workItems: [],
sourcePriorities: [], sourcePriorities: [],
convergence: [],
bundles: [],
singleSourcePolicyActions: [],
diagnostics: [], diagnostics: [],
provenance: [], provenance: [],
...overrides ...overrides
@ -195,8 +198,8 @@ describe('management overview structure', () => {
expect([...document.querySelectorAll('h1')].map(node => node.textContent)) expect([...document.querySelectorAll('h1')].map(node => node.textContent))
.toEqual(['Synthetic GmbH']); .toEqual(['Synthetic GmbH']);
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([ expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
'Overall Posture', 'Priority Findings', 'Remediation-Status', 'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken',
'Abdeckung und Lücken', 'Source Overview' 'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview'
]); ]);
// Every section is labelled for assistive technology. // Every section is labelled for assistive technology.
for (const section of document.querySelectorAll('section.segment')) { for (const section of document.querySelectorAll('section.segment')) {

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildReportProjection } from '../../src/domain/report-projection';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { buildTechnicalReportHtml } from '../../src/exports/technical-report';
describe('Technical Report', () => {
it('renders source-backed actual/expected values, exact GPO instructions and all policy states without scripts', () => {
const result = {
resultProjectionId: 'dummy-result', sourceKind: 'policy-evaluation', sourceResultId: 'dummy-source',
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'GPOs',
title: '<Dummy Registry Rule>', sourceRuleId: 'registry:dummy', isDeviation: true,
severityNormalized: 'unknown', outcomeNormalized: 'fail', policyActualValue: 0, policyBaselineValue: 1,
policyComparisonRelation: 'different', policyDiagnosticCodes: ['dummy-conflict'],
frameworkReferences: [{ namespace: 'GPOs', value: 'MITRE T1110' }]
} as unknown as UnifiedResultProjection;
const projection = buildReportProjection({
generatedAt: '2026-09-23T10:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' },
results: [result], domains: [{ id: 'gpo', label: 'GPO & Baseline' }], domainOf: () => 'gpo',
policyEntries: [{ id: 'dummy-setting', verdict: 'Fail', conflict: true, duplicate: false,
title: 'Dummy Registry Rule', scope: 'machine', identity: 'dummy-key', backupInstanceIds: ['dummy-backup'] }],
technicalPolicySources: [{ settingId: 'dummy-setting', sources: [{
id: 'dummy-source', backupInstanceId: 'dummy-backup', gpoId: '{DUMMY-GUID}',
nativeBackupId: '{DUMMY-BACKUP}', sourceReference: 'dummy/source', sequence: 2,
values: [{ kind: 'registry-policy', operation: 'set-value', data: 0, typeCode: 'REG_DWORD' }],
deviationFields: [['data']]
}] }]
});
const html = buildTechnicalReportHtml(projection);
expect(html).toContain('<html lang="de">');
expect(html).toContain('&lt;Dummy Registry Rule&gt;');
expect(html).not.toContain('<Dummy Registry Rule>');
expect(html).toContain('Konfiguriert');
expect(html).toContain('Erwartet');
expect(html).toContain('dummy/source');
expect(html).toContain('dummy-backup');
expect(html).toContain('MITRE T1110');
expect(html).toContain('<dd class="different">0</dd>');
for (const verdict of ['Deviation', 'Passed', 'Not configured', 'No baseline rule', 'Not comparable', 'Conflicts', 'Duplicates']) {
expect(html).toContain(verdict);
}
expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert');
expect(html).not.toMatch(/<script\b|<link\b|https?:\/\//i);
});
});

View File

@ -31,6 +31,7 @@ function result(id: string): UnifiedResultProjection {
reviewStatus: 'unreviewed', reviewStatus: 'unreviewed',
importedAt: '2026-09-03T10:00:00.000Z', importedAt: '2026-09-03T10:00:00.000Z',
createdAt: '2026-09-03T10:00:00.000Z', createdAt: '2026-09-03T10:00:00.000Z',
frameworkReferences: [],
categories: [] categories: []
}; };
} }

View File

@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { buildClientReportProjection } from '../../src/features/dashboard/client-report-projection';
import { makeSheet } from '../../src/importers/sheet-factory';
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Dummy timeslot</span> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe('Client Info report buttons', () => {
it('exports separate HTML reports from every result in the selected Dummy timeslot, ignoring the Source filter', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
const sheet = makeSheet('Dummy', [
['Name', 'Status', 'Severity', 'Source: Scan'],
['Visible filtered row', 'Failed', 'High', 'Scanner A'],
['Other source row', 'Failed', 'Medium', 'Scanner B']
], '', 'dummy.csv', { isProjectSheet: true });
const dataset = { fileName: 'Dummy', sheets: [sheet], scanTool: 'Generic' } as never;
const app = {
dataset, activeSheet: sheet, activeProfile: genericProfile, reviews: {},
targetProjectId: 'dummy-project', activeScanRunIds: ['dummy-run'], activeAssessmentIds: ['dummy-assessment'],
facetFilters: { 'provenance:source-scan': { values: ['Scanner A'] } },
setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(),
resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn()
} as unknown as ReturnType<typeof useApp>;
vi.mocked(useApp).mockReturnValue(app);
const project = { id: 'dummy-project', name: 'Dummy Customer', createdAt: '', updatedAt: '', attributes: {} };
const scopes: Array<{ projectId: string; scanRunIds?: string[]; assessmentIds?: string[] }> = [];
const result = {
resultProjectionId: 'dummy-result', sourceKind: 'scanner-finding', sourceResultId: 'dummy-source',
projectId: 'dummy-project', assessmentId: 'dummy-assessment', collectionRunId: 'dummy-run', scanRunId: 'dummy-run',
importArtifactId: 'dummy-artifact-secret', sourceTool: 'Scanner B', sourceRuleId: 'DUMMY-RULE-123',
title: 'Dummy technical deviation', description: 'A synthetic control failed.', recommendation: 'Apply the fix.',
severityNormalized: 'high', outcomeNormalized: 'fail', isDeviation: true,
categories: [], frameworkReferences: [], policyActualValue: 0, policyBaselineValue: 1
} as unknown as UnifiedResultProjection;
vi.spyOn(AssessorApiClient.prototype, 'getReportProjection').mockImplementation(async (projectId, scanRunIds, assessmentIds) => {
scopes.push({ projectId, scanRunIds, assessmentIds });
return buildClientReportProjection({ project, dataset, results: [result],
assessmentIds, generatedAt: '2026-09-23T10:00:00Z' });
});
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({
version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-09-23T00:00:00Z',
coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
observations: [], sections: []
});
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
const downloads = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
await act(async () => { root.render(<DashboardPanel onOpenWork={vi.fn()} />); });
const click = async (label: string) => {
const button = [...host.querySelectorAll('button')].find(item => item.textContent?.trim() === label);
expect(button).toBeTruthy();
await act(async () => { button!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
};
await click('Management Report');
await click('Technical Report');
expect(scopes).toHaveLength(2);
expect(scopes.every(scope => scope.projectId === 'dummy-project'
&& scope.assessmentIds?.[0] === 'dummy-assessment'
&& scope.scanRunIds?.[0] === 'dummy-run')).toBe(true);
expect(downloads).toHaveBeenCalledTimes(2);
const management = downloads.mock.calls[0][0];
const technical = downloads.mock.calls[1][0];
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).not.toContain('DUMMY-RULE-123');
expect(management.text).not.toContain('dummy-artifact-secret');
expect(technical.text).toContain('DUMMY-RULE-123');
expect(technical.text).toContain('dummy-artifact-secret');
expect(technical.text).toContain('vollständiger Result-Index');
await act(async () => { root.unmount(); });
host.remove();
});
});

View File

@ -143,6 +143,9 @@ describe('client info environment detail', () => {
expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s); expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s);
expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s); expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s);
expect(styles).toMatch(/\.client-env-group-title\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s); expect(styles).toMatch(/\.client-env-group-title\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s);
expect(styles).toMatch(/\.client-env-group-header\s*\{[^}]*flex-wrap:\s*wrap;/s);
expect(styles).toMatch(/\.client-environment-sources > header\s*\{[^}]*flex-wrap:\s*wrap;/s);
expect(styles).toMatch(/\.client-environment-results-button\s*\{[^}]*margin-inline-start:\s*auto;/s);
expect(styles).toMatch(/\.client-environment-sources > header h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s); expect(styles).toMatch(/\.client-environment-sources > header h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s);
expect(styles).toMatch(/\.client-command-center \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s); expect(styles).toMatch(/\.client-command-center \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s);
expect(styles).not.toMatch(/\.client-env-group-title::after\s*\{/); expect(styles).not.toMatch(/\.client-env-group-title::after\s*\{/);

View File

@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection } from '../../src/domain/environment-context';
import { EnvironmentEvidenceTables } from '../../src/features/dashboard/EnvironmentEvidenceTables';
import { environmentEvidenceForGroup, environmentSectionTitle, environmentTableSubsection } from '../../src/features/dashboard/environment-result-evidence';
const tool = { id: 'dummy', name: 'Dummy scanner' };
const section = (key: string, name: string, rows: (string | number)[][]): ScannerReportSection => ({
version: 1, id: key, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
sectionKey: key, label: name, ordinal: 1, state: 'observed',
tables: [{ name, headers: ['Technical setting', 'Configured value', 'Extra evidence'], rows }],
prose: `${name} source prose`, source: { rowIndex: 4 }, extractorVersion: 'dummy-1', attributes: { source: 'dummy' }
});
const observation = (property: string, originalValue: string | number | boolean, normalizedValue?: string | number | boolean): EnvironmentObservation => ({
version: 1, id: property, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
subject: { type: 'domain', label: 'DUMMY' }, property, label: property,
originalValue, normalizedValue, valueType: 'string', state: 'observed',
sectionKey: 'report-overview', source: { objectPath: '$.dummy.value' }, mappingVersion: 'dummy-2',
attributes: { role: 'test' }
});
const snapshot: EnvironmentSnapshot = {
version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-01-01T00:00:00Z',
coverage: { observed: 1, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
sections: [
section('sectionPasswordPolicies', 'Password policies', [['Minimum length', 14, 'GPO A'], ['History', 24, 'GPO B']]),
section('sectionPKI', 'Certificate templates', [['Template A', 'Enabled', 'Enrollment enabled']]),
section('sectionComputerInformation', 'Operating systems', [['Windows Server', 7, 'Unsupported count']]),
section('unknown-section', 'Unmapped report evidence', [['Unknown fact', 'retained', 'source-only']])
],
observations: [
observation('domain.count', 2, 3), observation('password_policy.minimum_length', 14),
observation('pki.template_count', 1), observation('operating_system.name', 'Windows Server'),
observation('domain.controller_count', 2), observation('entra.tenant_name', 'dummy tenant'),
observation('unmapped.custom_property', 'keep me')
]
};
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
describe('Environment Results source evidence', () => {
it('uses only the matching retained subsection heading and fails closed on conflicting evidence', () => {
const userSection = { ...section('sectionUserInformation', 'Honey Pot', []), tables: [
{ name: 'Account list', sourceTableIndex: 38, headers: ['Name'], rows: [['Dummy']] },
{ name: 'Tabelle 40', sourceTableIndex: 39, headers: ['Nb User Accounts'], rows: [[590]] }
] };
const honeyPot = { ...observation('pingcastle.honeypot.name', 'Dummy'), sectionKey: userSection.sectionKey,
attributes: { sourceTableIndex: 38, subsectionLabel: 'Honey Pot' } };
const accountAnalysis = { ...observation('pingcastle.account.count', 590), sectionKey: userSection.sectionKey,
attributes: { sourceTableIndex: 39, subsectionLabel: 'Account analysis' } };
const matchingSnapshot = { ...snapshot, sections: [userSection], observations: [honeyPot, accountAnalysis] };
expect(environmentSectionTitle(matchingSnapshot, userSection)).toBe('User Information');
expect(environmentTableSubsection(matchingSnapshot, userSection, userSection.tables[0])).toBe('Honey Pot');
expect(environmentTableSubsection(matchingSnapshot, userSection, userSection.tables[1])).toBe('Account analysis');
expect(environmentTableSubsection({ ...matchingSnapshot, observations: [honeyPot,
{ ...accountAnalysis, importFileId: 'other-file' }] }, userSection, userSection.tables[1])).toBeUndefined();
expect(environmentTableSubsection({ ...matchingSnapshot, observations: [honeyPot, accountAnalysis,
{ ...accountAnalysis, id: 'conflict', attributes: { sourceTableIndex: 39, subsectionLabel: 'Different heading' } }] },
userSection, userSection.tables[1])).toBeUndefined();
});
it('keeps category evidence separate while Source Overview retains every original source section and observation', () => {
expect(environmentEvidenceForGroup(snapshot, 'password-policy').sections.map(item => item.sectionKey)).toEqual(['sectionPasswordPolicies']);
expect(environmentEvidenceForGroup(snapshot, 'password-policy').observations.map(item => item.property)).toEqual(['password_policy.minimum_length']);
expect(environmentEvidenceForGroup(snapshot, 'certificates').sections.map(item => item.sectionKey)).toEqual(['sectionPKI']);
expect(environmentEvidenceForGroup(snapshot, 'inventory').observations.map(item => item.property)).toContain('domain.controller_count');
expect(environmentEvidenceForGroup(snapshot, 'source-metrics').observations.map(item => item.property)).toContain('entra.tenant_name');
expect(environmentEvidenceForGroup(snapshot, 'operating-systems').sections[0].tables[0].rows).toEqual([['Windows Server', 7, 'Unsupported count']]);
expect(environmentEvidenceForGroup(snapshot, 'all').sections).toHaveLength(snapshot.sections.length);
expect(environmentEvidenceForGroup(snapshot, 'all').observations).toHaveLength(snapshot.observations.length);
});
it('shows original table rows, source metadata and distinct original/normalized values instead of the tile summary', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={snapshot} group="password-policy"/>));
expect(container.textContent).toContain('Minimum length');
expect(container.textContent).toContain('History');
expect(container.textContent).toContain('GPO B');
expect(container.textContent).toContain('Password policies source prose');
expect(container.textContent).toContain('dummy-run');
expect(container.textContent).toContain('dummy-1');
expect([...container.querySelectorAll('.client-env-evidence-meta tr')].every(row => row.children.length === 2)).toBe(true);
expect(container.textContent).not.toContain('Enrollment enabled');
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={snapshot} group="all"/>));
expect(container.textContent).toContain('Unmapped report evidence');
expect(container.textContent).toContain('keep me');
const domainRow = [...container.querySelectorAll('tr')].find(row => row.textContent?.includes('domain.count'));
expect(domainRow?.textContent).toContain('2');
expect(domainRow?.textContent).toContain('3');
expect(domainRow?.textContent).toContain('$.dummy.value');
} finally { await act(async () => root.unmount()); container.remove(); }
});
it('provides all native rows through the Show more action without truncating the source table', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const longSnapshot = { ...snapshot, sections: [section('sectionPasswordPolicies', 'Long policies',
Array.from({ length: 101 }, (_, index) => [`Rule ${index + 1}`, index, `Evidence ${index + 1}`]))], observations: [] };
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={longSnapshot} group="password-policy"/>));
expect(container.textContent).toContain('Rule 100');
expect(container.textContent).not.toContain('Rule 101');
await act(async () => container.querySelector<HTMLButtonElement>('button')?.click());
expect(container.textContent).toContain('Rule 101');
} finally { await act(async () => root.unmount()); container.remove(); }
});
});

View File

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { makeSheet } from '../../src/importers/sheet-factory';
import { loadCanonicalProjectWorkspace } from '../../src/features/project-import/load-canonical-project-workspace';
import { assembleReportProjectionInput } from '../../src/features/reports/report-input-assembler';
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({
loadCanonicalProjectWorkspace: vi.fn()
}));
describe('report input assembly', () => {
it('freezes only selected Dummy source facts on export without inventing environment facts or a policy verdict', async () => {
const sheet = makeSheet('Dummy', [['Title', 'Source: Scan'], ['Synthetic check', 'PingCastle']],
'', 'dummy.csv', { isProjectSheet: true });
vi.mocked(loadCanonicalProjectWorkspace).mockResolvedValue({
models: [], assessmentIds: ['dummy-assessment'],
dataset: { fileName: 'Dummy', sheets: [sheet], scanTool: 'PingCastle' } as never
});
const result = {
resultProjectionId: 'dummy-result', sourceKind: 'scanner-finding', sourceResultId: 'dummy-source',
projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'dummy-run',
collectionRunId: 'dummy-run', title: 'Print Spooler hardening', sourceTool: 'PingCastle',
severityNormalized: 'medium', outcomeNormalized: 'fail', isDeviation: true,
categories: [], frameworkReferences: [], policyActualValue: 0, policyBaselineValue: 1
} as unknown as UnifiedResultProjection;
const queryUnifiedResults = vi.fn(async (query: { sourceKind?: string[] }) => ({
items: query.sourceKind?.includes('scanner-finding') ? [result] : [],
total: query.sourceKind?.includes('scanner-finding') ? 1 : 0,
offset: 0, limit: 500
}));
const repository = {
getProject: vi.fn(async () => ({ id: 'dummy-project', name: 'Dummy Customer', attributes: {}, createdAt: '', updatedAt: '' })),
queryUnifiedResults,
getEnvironmentSnapshot: vi.fn(async () => ({ observations: [] })),
listReviews: vi.fn(async () => []),
listProjectProcessingRuns: vi.fn(async () => [{
id: 'other-timeslot-processing', artifactId: 'other-artifact', diagnosticCodes: ['other-diagnostic'],
status: 'complete', processors: [], completedAt: '2026-09-01T00:00:00Z'
}]),
listGpoPolicyAnalyses: vi.fn(async () => []),
listPolicySnapshotAggregations: vi.fn(async () => []),
listPolicyEvaluations: vi.fn(async () => [])
} as unknown as AssessorRepository;
const input = await assembleReportProjectionInput(repository, {
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], scanRunIds: ['dummy-run'],
generatedAt: '2026-09-23T10:00:00Z'
});
expect(input.results).toEqual([result]);
expect(input.domainOf(result)).toBe('hardening');
expect(input.environment).toEqual([
{ key: 'Domain', value: undefined }, { key: 'Forest', value: undefined },
{ key: 'Domain Controller', value: undefined }
]);
expect(input.policyEntries).toEqual([]);
expect(input.delta).toBeUndefined();
expect(input.diagnostics).toEqual([]);
expect(input.provenance?.some(entry => entry.id === 'other-timeslot-processing')).toBe(false);
expect(queryUnifiedResults).toHaveBeenCalledWith(expect.objectContaining({
audience: 'internal-analysis', projectId: 'dummy-project', assessmentIds: ['dummy-assessment'],
scanRunIds: ['dummy-run']
}));
expect(repository.getEnvironmentSnapshot).toHaveBeenCalledWith(expect.objectContaining({
projectId: 'dummy-project', scanRunIds: ['dummy-run']
}));
});
});

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../../src/features/dashboard/security-domains';
const titles: Record<string, string> = {
identity: 'Inactive user accounts', privilege: 'Privileged admin delegation',
authentication: 'Legacy Kerberos authentication', infrastructure: 'Domain controller replication',
hardening: 'Print Spooler hardening', gpo: 'Group Policy baseline setting',
cloud: 'Entra application access', recovery: 'Audit logging and backup'
};
describe('shared report security-domain assignment', () => {
it('agrees with Work for all eight catalog entries, explicit GPO and honest Unclassified', () => {
for (const domain of securityDomainCatalog) {
const title = titles[domain.id];
const record = { title, fields: [] } as unknown as FindingRecord;
const result = { title, description: '', sourceKind: 'scanner-finding', categories: [] } as unknown as UnifiedResultProjection;
expect(securityDomainIdForRecord(record)).toBe(domain.id);
expect(securityDomainIdForUnifiedResult(result)).toBe(domain.id);
}
const gpoRecord = { title: 'Synthetic setting', fields: [{ label: 'Category', value: 'GPOs', index: 0 }] } as FindingRecord;
const gpoResult = { title: 'Synthetic setting', sourceKind: 'policy-evaluation', categories: [] } as unknown as UnifiedResultProjection;
expect(securityDomainIdForRecord(gpoRecord)).toBe('gpo');
expect(securityDomainIdForUnifiedResult(gpoResult)).toBe('gpo');
const unknownRecord = { title: 'Unmapped synthetic control', fields: [] } as unknown as FindingRecord;
const unknownResult = { title: unknownRecord.title, sourceKind: 'scanner-finding', categories: [] } as unknown as UnifiedResultProjection;
expect(securityDomainIdForRecord(unknownRecord)).toBe('unclassified');
expect(securityDomainIdForUnifiedResult(unknownResult)).toBe('unclassified');
});
});

View File

@ -15,7 +15,7 @@ function result(id: string, classification: NonNullable<UnifiedResultProjection[
outcomeOriginal: classification, outcomeNormalized: classification === 'misconfigured' ? 'fail' : classification === 'match' ? 'pass' : 'informational', outcomeOriginal: classification, outcomeNormalized: classification === 'misconfigured' ? 'fail' : classification === 'match' ? 'pass' : 'informational',
technicalExecutionState: 'completed', resultType: 'policy_deviation', isIoe: false, technicalExecutionState: 'completed', resultType: 'policy_deviation', isIoe: false,
isDeviation: classification === 'misconfigured', lifecycleState: 'unknown', mappingConfidence: 1, isDeviation: classification === 'misconfigured', lifecycleState: 'unknown', mappingConfidence: 1,
reviewStatus: 'unreviewed', importedAt: '2026-09-18T00:00:00Z', createdAt: '2026-09-18T00:00:00Z', categories: [] reviewStatus: 'unreviewed', importedAt: '2026-09-18T00:00:00Z', createdAt: '2026-09-18T00:00:00Z', frameworkReferences: [], categories: []
}; };
} }
@ -75,7 +75,7 @@ describe('Result DB policy filter consistency', () => {
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed', outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed',
resultType: 'exposure_finding', isIoe: true, isDeviation: true, exposureState: 'detected', resultType: 'exposure_finding', isIoe: true, isDeviation: true, exposureState: 'detected',
lifecycleState: 'unknown', mappingConfidence: 1, reviewStatus: 'unreviewed', lifecycleState: 'unknown', mappingConfidence: 1, reviewStatus: 'unreviewed',
importedAt: '2026-09-22T00:00:00Z', createdAt: '2026-09-22T00:00:00Z', categories: [] importedAt: '2026-09-22T00:00:00Z', createdAt: '2026-09-22T00:00:00Z', frameworkReferences: [], categories: []
}; };
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div'); const container = document.createElement('div');
@ -104,7 +104,7 @@ describe('Result DB policy filter consistency', () => {
outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'exposure_finding', outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'exposure_finding',
isIoe: true, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1, isIoe: true, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1,
reviewStatus: 'unreviewed', importedAt: '2026-09-23T00:00:00Z', reviewStatus: 'unreviewed', importedAt: '2026-09-23T00:00:00Z',
createdAt: '2026-09-23T00:00:00Z', categories: [] createdAt: '2026-09-23T00:00:00Z', frameworkReferences: [], categories: []
}; };
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div'); const container = document.createElement('div');

View File

@ -3,11 +3,13 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider'; import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar'; import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile'; import { genericProfile } from '../../src/profiles/generic/generic-profile';
import type { ImportedDataset } from '../../src/domain/dataset'; import type { ImportedDataset } from '../../src/domain/dataset';
import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
import type { DynamicFacetFilter } from '../../src/features/search-filter/dynamic-facets'; import type { DynamicFacetFilter } from '../../src/features/search-filter/dynamic-facets';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
@ -38,6 +40,119 @@ function fixture() {
} }
describe('source selection preserves the current view', () => { describe('source selection preserves the current view', () => {
it('opens the real Results action with retained native rows that are not on the Environment tiles', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
const state = fixture();
state.targetProjectId = 'dummy-project';
const tool = { id: 'dummy', name: 'Dummy scanner' };
const makeSection = (id: string, tableName: string, extra: string) => ({
version: 1 as const, id, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
sectionKey: id, label: tableName, ordinal: 1, state: 'observed' as const,
tables: [{ name: tableName, headers: ['Name', 'Value', 'Native detail'], rows: [['Synthetic fact', '1', extra]] }],
source: { rowIndex: 5 }, extractorVersion: 'dummy-1', attributes: {}
});
const snapshot: EnvironmentSnapshot = {
version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-01-01T00:00:00Z',
coverage: { observed: 3, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
sections: [
makeSection('sectionPasswordPolicies', 'Password policies', 'Native password detail'),
makeSection('sectionComputerInformation', 'Operating systems', 'Native OS detail'),
{ ...makeSection('sectionUserInformation', 'Honey Pot', 'unused'), label: 'Honey Pot', tables: [
{ name: 'Account list', sourceTableIndex: 38, headers: ['Name'], rows: [['Dummy honeypot']] },
{ name: 'Tabelle 40', sourceTableIndex: 39, headers: ['Nb User Accounts'], rows: [[590]] }
] },
makeSection('other-section', 'Unclassified native table', 'Native unmapped detail')
], observations: [
{ version: 1, id: 'dummy-honeypot', scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
subject: { type: 'user-information.honey-pot' }, property: 'pingcastle.honeypot.name', label: 'Name',
originalValue: 'Dummy honeypot', valueType: 'string', state: 'observed', sectionKey: 'sectionUserInformation',
source: { rowIndex: 1 }, mappingVersion: 'dummy-1', attributes: { sourceTableIndex: 38, subsectionLabel: 'Honey Pot' } },
{ version: 1, id: 'dummy-account-analysis', scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
subject: { type: 'user-information.account-analysis' }, property: 'pingcastle.account.count', label: 'Nb User Accounts',
originalValue: 590, valueType: 'number', state: 'observed', sectionKey: 'sectionUserInformation',
source: { rowIndex: 1 }, mappingVersion: 'dummy-1', attributes: { sourceTableIndex: 39, subsectionLabel: 'Account analysis' } }
]
};
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container);
const open = async (title: string) => {
const tile = [...container.querySelectorAll<HTMLElement>('.client-environment-grid > section')]
.find(item => item.querySelector('h3')?.textContent?.includes(title));
const trigger = tile?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]');
expect(trigger).toBeTruthy();
await act(async () => trigger?.click());
return document.body.querySelector<HTMLElement>('.floating-data-window')!;
};
const close = async (title: string) => {
await act(async () => document.body.querySelector<HTMLButtonElement>(`button[aria-label="Close ${title} window"]`)?.click());
};
try {
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
const password = await open('Password policy');
expect(password.textContent).toContain('Native password detail');
expect(password.textContent).not.toContain('Native OS detail');
await close('Password policy');
const inventory = await open('Inventory');
expect([...inventory.querySelectorAll('h4')].map(item => item.textContent)).toContain('User Information');
expect([...inventory.querySelectorAll('h5')].map(item => item.textContent)).toEqual(expect.arrayContaining([
expect.stringContaining('Honey Pot'), expect.stringContaining('Account analysis')
]));
expect(inventory.textContent).not.toContain('Tabelle 40');
expect(inventory.textContent).not.toContain('Source table index');
expect(inventory.textContent).not.toContain('sourceTableIndex');
await close('Inventory');
const os = await open('Operating systems');
expect(os.textContent).toContain('Native OS detail');
await close('Operating systems');
const overview = await open('Source Overview');
expect(overview.textContent).toContain('Source result contributions');
expect(overview.textContent).toContain('Source finding records');
expect(overview.textContent).toContain('Synthetic A');
expect(overview.textContent).toContain('Native password detail');
expect(overview.textContent).toContain('Native OS detail');
expect(overview.textContent).toContain('Native unmapped detail');
await close('Source Overview');
} finally { await act(async () => root.unmount()); container.remove(); }
});
it('opens Results from every Environment Detail tile with its own data and restores focus', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
fixture();
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const titles = ['Source Overview', 'Directory', 'Resilience', 'Inventory', 'Password policy',
'Certificates & PKI', 'Source metrics', 'Operating systems'];
try {
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
const tiles = [...container.querySelectorAll<HTMLElement>('.client-environment-grid > section')];
expect(tiles).toHaveLength(titles.length);
for (const [index, title] of titles.entries()) {
const trigger = tiles[index].querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]');
expect(trigger?.textContent).toBe('Results');
expect(trigger?.getAttribute('aria-label')).toBe(`Open ${title} results`);
expect(trigger?.classList.contains('results-btn')).toBe(true);
await act(async () => trigger?.click());
const floating = document.body.querySelector<HTMLElement>('.floating-data-window');
expect(floating?.querySelector('h2')?.textContent).toBe(title);
expect(floating?.querySelector('.floating-data-window-body')?.textContent?.trim()).not.toBe('');
if (title === 'Source Overview') expect(floating?.textContent).toContain('Scanner A');
if (title === 'Directory') expect(floating?.textContent).toContain('No retained scanner context available');
if (title === 'Operating systems') expect(floating?.textContent).toContain('No classified inventory available');
const close = [...(floating?.querySelectorAll<HTMLButtonElement>('.floating-data-window-actions button') ?? [])]
.find(button => button.getAttribute('aria-label') === `Close ${title} window`);
expect(close).toBeTruthy();
await act(async () => close?.click());
expect(document.body.querySelector('.floating-data-window')).toBeNull();
expect(document.activeElement).toBe(trigger);
}
} finally { await act(async () => root.unmount()); container.remove(); }
});
it('shows the Data tile first and opens its deviation count in Work', async () => { it('shows the Data tile first and opens its deviation count in Work', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const state = fixture(); const state = fixture();

View File

@ -90,6 +90,12 @@ describe('PingCastle native finding extraction', () => {
const model = createCanonicalImportBatch('environment-batch', [entry], '2026-08-20T12:00:00.000Z').model; const model = createCanonicalImportBatch('environment-batch', [entry], '2026-08-20T12:00:00.000Z').model;
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0); expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0); expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
expect(model.environmentObservations).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Honeypot',
attributes: expect.objectContaining({ subsectionLabel: 'Honey Pot', sourceTableIndex: expect.any(Number) }) }),
expect.objectContaining({ label: 'Nb User Accounts', originalValue: '12',
attributes: expect.objectContaining({ subsectionLabel: 'Account analysis', sourceTableIndex: expect.any(Number) }) })
]));
expect(model.importFiles[0].processedWith).toMatchObject({ expect(model.importFiles[0].processedWith).toMatchObject({
profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0', profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1' compatibilityKey: 'pingcastle-canonical-v1.1'

View File

@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`); const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({ expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.5.12', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' status: 'ok', application: 'ASSESSOR', productVersion: '2.6.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
}); });
expect(health.headers.get('access-control-allow-origin')).toBeNull(); expect(health.headers.get('access-control-allow-origin')).toBeNull();
}); });
@ -116,6 +116,13 @@ describe('local ASSESSOR HTTP server', () => {
await expect(environmentResults.json()).resolves.toEqual({ total: 0, items: [] }); await expect(environmentResults.json()).resolves.toEqual({ total: 0, items: [] });
const environmentResult = await fetch(`${baseUrl}/api/v1/results/environment-observation%3Aobservation-1`); const environmentResult = await fetch(`${baseUrl}/api/v1/results/environment-observation%3Aobservation-1`);
expect(environmentResult.status).toBe(404); expect(environmentResult.status).toBe(404);
const report = await fetch(`${baseUrl}/api/v1/projects/${project.id}/report-projection?assessmentIds=${assessment.id}&scanRunIds=${scanRun.id}`);
expect(report.status).toBe(200);
await expect(report.json()).resolves.toMatchObject({
version: '1.1.0', project: { id: project.id }, assessmentId: assessment.id,
environment: expect.arrayContaining([{ key: 'Domain' }, { key: 'Forest' }, { key: 'Domain Controller' }]),
coverage: { total: 0 }, diagnostics: expect.any(Array), provenance: expect.any(Array)
});
}); });
it('validates import metadata before file content reaches a future server parser', async () => { it('validates import metadata before file content reaches a future server parser', async () => {