Client Info Anpassung + weiter GPO Implementierung
This commit is contained in:
parent
f4f5f3dff5
commit
4cdf54967d
|
|
@ -373,6 +373,42 @@ Abnahmekriterien:
|
||||||
|
|
||||||
Status: abgeschlossen
|
Status: abgeschlossen
|
||||||
|
|
||||||
|
Verifizierte Result-DB-Filter-/Exportnachschaerfung vom 2026-09-03: Die Result DB startet und resettiert mit `Deviation = Yes`. Ein neuer `Export JSONL`-Button exportiert nicht nur die geladene Seite, sondern die vollstaendige aktuelle Filter- und Timeslot-Ergebnismenge ueber stabile Seitenabfragen. Zeile eins dokumentiert Format-/Schemaversion, Zeitpunkt, Scope, angewandte Filter und Ergebnisanzahl; danach folgt je Result genau ein vollstaendiges JSON-Objekt. Aendert sich die Ergebnismenge waehrend des Exports, wird der Download fail-closed abgebrochen. Drei fokussierte Export-/Scope-Tests und der Gesamtlauf mit 767 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Balkenformatierung vom 2026-09-03: Die wiederverwendeten Kennzahlzeilen besitzen nun identische Label-, Balken- und Wertspalten sowie eine in jeder Zeile reservierte Statuszeile. Optionale `Restricted`-Hinweise verschieben dadurch keine Nachbarzeilen mehr; Zahlen, Einheiten und Balken bleiben ueber alle gleich breiten Karten ausgerichtet. Die kompakte Infrastructure-&-Resilience-Ausnahme behaelt ihre eigenstaendige Einspaltengeometrie. 13 fokussierte Dashboard-/Kacheltests, der Gesamtlauf mit 767 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierter Work-Kachelvertrag vom 2026-09-03: Regulaere Finding-Kacheln verwenden die Nutzerreferenz als verbindliches Mindestformat mit 358 px Breite und 223 px Hoehe. Das Raster streckt unvollstaendige Reihen nicht mehr auf Restbreite; Hexagon, Titel und rechte Aktionsspalte bleiben im Kopf. Die Metadaten folgen einer festen semantischen Matrix aus Status, Kategorie, Severity plus Weight und Score. Die Hoehe darf bei laengerem Inhalt weiter wachsen; Pathfinder-Micro-Cards bleiben von der Work-spezifischen Anordnung ausgenommen. 16 fokussierte Kachel-/Typografie-/Pathfinder-Tests, der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Infrastructure-&-Resilience-Korrektur vom 2026-09-03: Die inhaltsreiche Dashboardkachel reserviert drei statt zwei Rasterzeilen und ist als gezielte Ausnahme nicht mehr an ein starres Quadratverhaeltnis gebunden. Lange, vollstaendige OS-Aufzaehlungen bleiben dadurch innerhalb der Kartenflaeche; Faktenraster, Werte und die uebrigen quadratischen Dashboardkacheln bleiben unveraendert. 14 fokussierte Dashboard-/Inset-Tests, der Gesamtlauf mit 769 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Unmittelbare responsive Nutzerkorrektur vom 2026-09-03: Die feste Drei-Zeilen-Ausnahme wird durch inhaltsbasierte Rasterhoehen ersetzt. Jede Client-Info-Inhaltskarte behaelt ihre quadratische Mindestgeometrie, darf aber anhand ihres vollstaendigen Inhalts wachsen; `Infrastructure & Resilience` verwendet eine automatische Inhaltshoehe. Die breakpointabhaengige Rastereinheit bleibt als Mindesthoehe erhalten, waehrend `minmax(..., auto)` zusaetzlichen Platz ohne Ueberlauf bereitstellt. 14 fokussierte Dashboard-/Inset-Tests, der Gesamtlauf mit 770 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Progressive-Disclosure-Nachschaerfung vom 2026-09-03: Die sechs reinen Zahlenkacheln bleiben unveraendert quadratisch. Alle neun vorhandenen Detailbereiche starten dagegen als kompakte Telemetriekarten mit Titel, bestehender Metainformation, fachlicher Primaermenge und proportionalem Mengenbalken. Ein zugaenglicher Plus-/Minus-Schalter blendet die vollstaendigen bisherigen Donut-, Balken- und Fakteninhalte je Karte unabhaengig ein beziehungsweise aus. Erfundene Mockup-Bereiche wurden nicht uebernommen. Inhaltsbasierte Rasterhoehen verhindern Ueberlauf auch im expandierten Zustand. 15 fokussierte Dashboard-, Interaktions- und Inset-Tests, der Gesamtlauf mit 774 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Panelstil-Korrektur vom 2026-09-03: Die neun Infopanels folgen nun der ausdruecklichen Nutzerreferenz. Im minimierten Zustand ist ausschliesslich die luftige Kopfzeile `Titel — Primaermenge` mit Plus sichtbar; es gibt dort keinen Balken. Erst die aufgeklappte Ansicht zeigt hohe, grosszuegig getrennte Datenzeilen mit Label, Wert und einem 72 px dicken, breiten Mengenbalken. Assessment Status und Severity Distribution verwenden ebenfalls Mengenbalken statt Donuts, behalten aber ihre vorhandenen Filteraktionen. Faktenzeilen folgen derselben luftigen Vertikalgeometrie; die fruehere kompakte Resilience-Sonderdarstellung entfaellt. Die sechs reinen Zahlenkacheln bleiben unveraendert. 12 fokussierte Layout-/Interaktionstests, alle 774 Standardtests, Local-only- und Designpruefung sowie der Client-/Server-Produktionsbuild sind erfolgreich; 8 optionale PostgreSQL-Tests wurden erwartungsgemaess uebersprungen. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Unmittelbare Breitenkorrektur der Client-Info-Infopanels vom 2026-09-03: Die aufgeklappte Karte belegt im regulaeren Raster sechs statt weiterhin nur zwei Spalten. Unterhalb von 800 px verwendet sie alle vier, unterhalb von 560 px alle zwei verfuegbaren Spalten. Damit bleibt die bestaetigte linke Label-/Wertzone neben dem breiten rechten Balkenbereich innerhalb derselben Kartenflaeche; die minimierte Zwei-Spalten-Kopfzeile bleibt unveraendert. Der in der Kopfzeile bereits dargestellte Primaerwert wird expandiert nicht als erste Zeile dupliziert. Neutrale Analysepanels uebernehmen fuer ihre restlichen Mengen die rote, orange, gelbe, violette und bei Bedarf neutrale Reihenfolge der Referenz; fachlich bereits festgelegte Status-, Coverage-, PKI- und GPO-Farben bleiben erhalten. 12 fokussierte Layout-/Interaktionstests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Abschliessende bildbezogene Client-Info-Layoutkorrektur vom 2026-09-03: Die sechs quadratischen Zahlenkacheln bilden wieder eine eigene obere Sechserspur. Darunter stehen die sechs real vorhandenen Mengenpanels dauerhaft sichtbar in einem dreispaltigen Telemetrieraster; Plus/Minus steuert nur zusaetzliche Angaben und entfernt die Mengenzeilen nicht. Assessment Status und Severity Distribution sind wieder als echte Donut-Kacheln hergestellt und bilden gemeinsam mit Directory Overview die rechte Nebenspalte. Mengenzeilen verwenden die kompakte Referenzgeometrie mit 64 px Zeilenhoehe und 30 px dicken Balken; die bisherige uebergrosse 120-/72-px-Ausfuehrung und die expandierte Sechs-Spalten-Sonderbreite sind damit supersediert. Bei kleineren Viewports reduzieren sich Mengenraster und Donutspalte kontrolliert beziehungsweise stapeln sich. 12 fokussierte Layout-/Interaktionstests, die Gesamtsuite mit 774 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Erstreihenkorrektur vom 2026-09-03: Die obere Zone teilt sich jetzt im selben 3:1-Verhaeltnis wie der Inhaltsbereich. Die sechs weiterhin quadratischen Zahlenkacheln belegen verkleinert das linke Dreiviertel; `Assessment Status` steht als Donut-Kachel direkt rechts daneben in Reihe 1. Dadurch ruecken `Severity Distribution` und anschliessend `Directory Overview` in der rechten Rail jeweils nach oben. Unterhalb von 1100 px stapelt sich die Kopfzone und die Zahlenkacheln wechseln kontrolliert auf drei, unterhalb von 800 px auf zwei Spalten. 12 fokussierte Layout-/Interaktionstests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Open-Standardansicht vom 2026-09-03: Das direkte Oeffnen eines Clients beziehungsweise seiner ausgewaehlten Timeslots aus Data Hub fuehrt ab jetzt in `Client Info` statt `Work`. Projektname, Projekt-ID und die konkrete ScanRun-Auswahl werden unveraendert uebernommen; nur das initial aktive Hauptgebiet aendert sich. Bewusste spaetere Navigation zu Work, Result DB oder Pathfinder bleibt unveraendert. Neun fokussierte Data-Hub-/Header-/Clientdetailtests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Minimierkorrektur vom 2026-09-03: Alle neun Informationskacheln starten vollstaendig geoeffnet mit Minus und lassen sich nun ueber denselben zugaenglichen Schalter auf ihre reine Kopfzeile mit Plus minimieren. Das gilt einheitlich fuer die sechs Mengen-/Faktenkarten sowie Assessment Status, Severity Distribution und Directory Overview; beim erneuten Oeffnen wird der vollstaendige Inhalt wiederhergestellt. Minimierte Karten werden auch in Rasterzeilen mit hoeheren Nachbarkarten nicht mehr leer auf deren Hoehe gestreckt. Die sechs reinen Zahlenkacheln bleiben unveraendert. Die beiden gemeinsamen Kartenvertraege und alle neun konkreten Titel/Instanzen sind synthetisch beziehungsweise statisch regressionsgeprueft. 13 fokussierte Dashboard-/Interaktionstests, die Gesamtsuite mit 776 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Maester-Fehlerdarstellung vom 2026-09-03: Eindeutig erkannte `Invoke-MgGraphRequest`-Ausfuehrungsfehler werden im Results-Fenster nicht mehr als unstrukturierter Inline-Rohtext dargestellt. Die eigentliche Graph-Fehlermeldung steht als lesbare Primaeraussage oben; Fehlercode, HTTP-Status, Methode und Endpunkt, Skriptquelle, Zeit sowie vorhandene Request-IDs folgen als beschriftete strukturierte Felder. Der vollstaendige unveraenderte Quelltext bleibt unter `Technical details` einklappbar. Normale Maester-Ergebnisse und andere Werkzeuge verwenden weiterhin die generische Rich-Text-Darstellung. 48 fokussierte Result-/Reader-Tests und Client-/Server-Produktionsbuild, Local-only- sowie Designpruefung sind erfolgreich. Der parallele Gesamtlauf bestaetigt 777 Tests; genau eine fachlich unabhaengige statische Result-DB-Scope-Pruefung erwartet noch die alte Quelltextform vor der bereits parallel vorhandenen `assessmentIds`-Ergaenzung, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Infrastrukturtrennung vom 2026-09-03: Der Faktenblock mit `Marked findings`, AD Recycle Bin, Backupzeit, krbtgt-Aenderung, Legacy/Windows LAPS, WEF und vollstaendiger OS-Angabe ist aus `Infrastructure & Resilience` herausgeloest und steht als eigene minimierbare Kachel `Infrastructure Details` im dreispaltigen Inhaltsraster. Die Ursprungskachel enthaelt nur noch ihre Risiko-/Mengenwerte. Alle bestehenden Werte stammen unveraendert aus demselben normalisierten Environment Snapshot. Die Client-Info-Ansicht umfasst damit zehn statt neun Informationskacheln. 13 fokussierte Dashboard-/Interaktionstests, Local-only- und Designpruefung sind erfolgreich. Der Baseline-Build ist derzeit unabhaengig von dieser Aenderung durch eine parallel hinzugekommene GPO-Testfixture mit fehlendem Pflichtfeld `ImportProcessorReference.kind` blockiert; ein separater direkter Vite-Lauf war in der Sandbox wegen `spawn EPERM` nicht zulaessig. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Masonry-/Dreieckskorrektur vom 2026-09-03: Das Inhaltsraster misst jede der zehn Informationskacheln individuell und weist ihr eine eigene feingranulare Rasterspanne zu. Bei Collapse sinkt die Kartenhoehe auf die Kopfzeile; nachfolgende Karten belegen per dichtem Auto-Flow unmittelbar den frei gewordenen Platz, statt bis zum Ende einer hoeheren Nachbarkarte zu warten. Ohne `ResizeObserver` bleibt das regulaere nicht ueberlappende Raster als Fallback aktiv. Die Plus-/Minus-Zeichen sind durch die gemeinsamen SVG-Dreiecke ersetzt: unten fuer geoeffnet, rechts fuer geschlossen. 16 fokussierte Dashboard-, Interaktions-, Masonry- und Dreieckstests sowie Client-/Server-Produktionsbuild, Local-only- und Designpruefung sind erfolgreich. Der Gesamtlauf bestaetigt 781 Tests; genau eine unabhaengige statische Data-Hub-Open-Regression erwartet noch die alte einzeilige Quelltextformatierung, obwohl dieselbe `dashboard`-Navigation weiterhin mehrzeilig vorhanden ist; 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Spalten-/KPI-Korrektur vom 2026-09-03: Die freie Masonry-Neuanordnung ist zugunsten drei dauerhafter Inhalts-Spalten mit der Kartenverteilung 3/2/2 ersetzt. Karten wachsen und schrumpfen nur innerhalb ihrer zugewiesenen Spalte; beim Minimieren rueckt ausschliesslich der darunterliegende Inhalt derselben Spalte nach oben. Die sechs KPI-Kacheln strecken sich in der Desktop-Kopfzone auf die Hoehe des danebenstehenden `Assessment Status`, sodass zwischen Kopf- und Inhaltszone keine uebergrosse Leerstelle verbleibt. Die Reihenfolge lautet Compliance Score, Deviation, High Severity, Passed, Typed Observations, Directory Sections; nur Deviation und High Severity sind interaktiv. Alle KPI-Werte sind groesser, der Compliance Score verwendet die staerkste gemeinsame H1-Hierarchie. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Im Gesamtlauf bestanden 780 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet weiterhin eine einzeilige Quelltextformatierung der funktional vorhandenen mehrzeiligen `dashboard`-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Detailkorrektur vom 2026-09-03: Minimierte Informationskarten behalten nun mit `width: 100%` dieselbe volle Spaltenbreite wie ihre maximierte Form; nur die Kartenhoehe wird reduziert. Der werkzeugspezifische Hinweis `PingCastle` beziehungsweise dessen Engine-Version ist aus `Directory Overview` entfernt, waehrend die normalisierten Quelldaten unveraendert erhalten bleiben. 13 fokussierte Dashboard-/Interaktionstests und der Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
|
Verifizierte Client-Info-Standardansicht vom 2026-09-03: Beim Oeffnen ist ausschliesslich `Assessment Status` expandiert. Die acht uebrigen Mengen-/Faktenkarten sowie `Severity Distribution` starten als vollbreite minimierte Kopfzeilen mit rechtem Dreieck und koennen weiterhin individuell geoeffnet werden. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests und der Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
|
||||||
|
|
||||||
Ziele:
|
Ziele:
|
||||||
|
|
||||||
- Filter aus normalisierten Rollen und tatsächlichen Datenwerten erzeugen.
|
- Filter aus normalisierten Rollen und tatsächlichen Datenwerten erzeugen.
|
||||||
|
|
@ -1198,6 +1234,16 @@ Abnahmekriterien:
|
||||||
|
|
||||||
Status: in Arbeit
|
Status: in Arbeit
|
||||||
|
|
||||||
|
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Die bislang fehlende GPO-Anbindung an Work und Result DB ist als additives, timeslotgebundenes Readmodell geschlossen. Jede aktuelle rohe `PolicyObservation` wird als eigener Quelltyp `policy-observation` mit Assessment-, Artefakt-, Backup-, Parser-, Reducer-, Identity-, Wert-, Diagnose- und Locator-Provenienz projiziert. Diese Datensaetze bleiben fachlich `informational`, zeigen IOE und Deviation als nicht anwendbar und werden auch unter dem Result-DB-Standardfilter `Deviation = Yes` als Kontext sichtbar; nur die Finding Factory darf weiterhin eine echte `policy_deviation` erzeugen. Work fuegt `Group Policy · Observations` als eigenes Blatt hinzu und nimmt dieselben Zeilen in `All Records` auf, sodass `Source: Scan` dort `Group Policy` anbietet. Der freigegebene Dummy-Timeslot liefert ueber die laufende PostgreSQL-API 452 Scannerabweichungen plus 51 GPO-Beobachtungen, insgesamt 503 sichtbare Resultate. 13 fokussierte Tests und der Gesamtlauf mit 781 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine SQL-Migration und keine Veraenderung der importierten Originaldaten; Phase 13B bleibt fuer fachliche Identity-/Regelkataloge und bestaetigte Policy-Findings in Arbeit.
|
||||||
|
|
||||||
|
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Die bislang implementierte, aber nicht erreichbare Policy-Analyseoberflaeche ist nun aus dem Data-Hub-Clientdetail zugreifbar. Eine erfolgreiche GPO-Quellenzeile verwendet keine irrefuehrende deaktivierte Scanner-Auswahlbox mehr, sondern eine eigene Quellenmarkierung und eine `View`-Aktion. Diese oeffnet die projektgebundene GPO-Analyse mit Reprocessing, Environment Profiles, Aggregation, Evaluation, Finding Factory, aktueller Summary, Historie und Review Queue; zur Stammdatenansicht kann direkt zurueckgewechselt werden. `Source: Scan` bleibt fachlich auf Scanner-Runs begrenzt. Sieben fokussierte Data-Hub-/Policy-UI-Tests, die Gesamtsuite mit 775 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit.
|
||||||
|
|
||||||
|
Aktueller verifizierter Zusatzstatus vom 2026-09-03: GPO-Backups sind im ausgewaehlten Data-Hub-Timeslot nun als eigene Quellenzeilen sichtbar und werden im Quellenzaehler beruecksichtigt. Die Ansicht aktualisiert sich unmittelbar nach einer GPO-Einreichung; Queue-, Verarbeitungs-, Teil-, Erfolgs-, Recovery- und Dead-Letter-Zustaende bleiben unterscheidbar, und fehlgeschlagene Importe koennen direkt wiederholt oder geloescht werden. Der Browser bietet getrennte Eingaben fuer einen normalen GPO-Backup-Ordner und ein bereits vorhandenes ZIP. Ein Ordner wird weiterhin lokal in ein deterministisches Transport-ZIP verpackt, waehrend ein ZIP unveraendert dieselbe projekt- und timeslotgebundene, gehaertete Backend-Ingestion durchlaeuft. 14 fokussierte Data-Hub-/Transport-/HTTP-Tests sowie die Gesamtsuite mit 774 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit.
|
||||||
|
|
||||||
|
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Der bisher nur eingebettete fachlich leere Policy-Evaluation-Regelkatalog besitzt nun eine lokale, betriebsseitig kontrollierte Ladegrenze. Ein kuratierter JSON-Katalog wird ausschliesslich bei gemeinsam konfiguriertem Dateipfad und SHA-256 geladen, ist auf 5 MiB und regulaere Nicht-Symlink-Dateien begrenzt und wird erst nach Hash-, UTF-8-/JSON-, Struktur- und vollstaendiger fachlicher Katalogvalidierung aktiviert. Jede Abweichung verhindert den Serverstart noch vor Aufbau der Repository-Laufzeit; ohne Konfiguration bleibt der sichere leere Freigabekatalog aktiv. Drei neue synthetische Loaderregressionen, 28 fokussierte Katalog-/Evaluations-/HTTP-Tests sowie die Gesamtsuite mit 773 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Netzwerk-, API-, SQL- oder Persistenzaenderung; fachliche Regeln und Windows-Inbox-ADMX-/ADML-Inhalte bleiben bis zur autorisierten Kuratierung weiterhin leer beziehungsweise offen.
|
||||||
|
|
||||||
|
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Die offene snapshotbezogene Ergebnisdeduplizierung ist als additive aktuelle Sicht im Policy-Analyse-Readmodell umgesetzt. Die unveraenderliche Run-Historie bleibt vollstaendig erhalten; Summary und Review Queue zaehlen dagegen nur die aktiven Ketten aus nicht ersetztem Environment Profile, neuester nicht ersetzter Identity-Analyse je Artefakt, dazu passender Snapshotaggregation, neuester Evaluation je Aggregation und neuestem Finding-Run je Evaluation. Veraltete Ketten werden auch nicht mehr als ausfuehrbare Evaluate-/Finding-Aktionen angeboten. Drei Domainregressionen sowie Panel-, Actions- und HTTP-Regression sind erfolgreich; Gesamtsuite mit 770 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind gruen. Keine Persistenz- oder SQL-Aenderung; das Readmodell wurde additiv erweitert und Phase 13B bleibt fuer fachliche Regel-/Inbox-ADMX-Kuratierung, weitere Preference-CSEs und Exportdetails in Arbeit.
|
||||||
|
|
||||||
Aktueller verifizierter Zusatzstatus vom 2026-08-26: Die Freigabegrenze des weiterhin fachlich leeren Policy-Evaluation-Regelkatalogs validiert nun alle Runtime-Enumwerte fuer Auslieferungsmechanismus, Baseline-Disposition, Comparator und Severity, blockiert ueberlappende Selektoren beziehungsweise mehrdeutige Regeln und lehnt widerspruechlich wiederverwendete Risiko- oder Abhaengigkeits-IDs fail-closed ab. Es wurden keine Policyempfehlung und keine Severity erfunden oder freigegeben. Der lokale private Artefaktspeicher wurde vom veralteten fremden Benutzerpfad auf den aktuellen LocalAppData-Pfad ausserhalb des Webroots korrigiert; Backend, App und Speicherroot sind inhaltsfrei technisch erreichbar. Neun neue Katalogregressionen, 25 fokussierte GPO-/Katalogtests, die Gesamtsuite mit 764 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit.
|
Aktueller verifizierter Zusatzstatus vom 2026-08-26: Die Freigabegrenze des weiterhin fachlich leeren Policy-Evaluation-Regelkatalogs validiert nun alle Runtime-Enumwerte fuer Auslieferungsmechanismus, Baseline-Disposition, Comparator und Severity, blockiert ueberlappende Selektoren beziehungsweise mehrdeutige Regeln und lehnt widerspruechlich wiederverwendete Risiko- oder Abhaengigkeits-IDs fail-closed ab. Es wurden keine Policyempfehlung und keine Severity erfunden oder freigegeben. Der lokale private Artefaktspeicher wurde vom veralteten fremden Benutzerpfad auf den aktuellen LocalAppData-Pfad ausserhalb des Webroots korrigiert; Backend, App und Speicherroot sind inhaltsfrei technisch erreichbar. Neun neue Katalogregressionen, 25 fokussierte GPO-/Katalogtests, die Gesamtsuite mit 764 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit.
|
||||||
|
|
||||||
Aktueller verifizierter Zusatzstatus vom 2026-08-22: Der globale Microsoft-Security-Baselinekatalog besitzt nun einen bestaetigungspflichtigen Delete-Pfad durch UI, Same-Origin-API, Service sowie Local- und PostgreSQL-Repository. Nur nicht von Policy-Evaluationen referenzierte Pakete duerfen geloescht werden; Klassifikationen und gespeicherte Analysen werden zusammen mit dem Katalogeintrag entfernt, das private Originalobjekt anschliessend best-effort bereinigt. Keine SQL-Migration; Phase 13B bleibt in Arbeit.
|
Aktueller verifizierter Zusatzstatus vom 2026-08-22: Der globale Microsoft-Security-Baselinekatalog besitzt nun einen bestaetigungspflichtigen Delete-Pfad durch UI, Same-Origin-API, Service sowie Local- und PostgreSQL-Repository. Nur nicht von Policy-Evaluationen referenzierte Pakete duerfen geloescht werden; Klassifikationen und gespeicherte Analysen werden zusammen mit dem Katalogeintrag entfernt, das private Originalobjekt anschliessend best-effort bereinigt. Keine SQL-Migration; Phase 13B bleibt in Arbeit.
|
||||||
|
|
@ -1357,7 +1403,7 @@ Abnahmekriterien:
|
||||||
Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort:
|
Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort:
|
||||||
|
|
||||||
1. Phase 13A ist abgeschlossen: Erkennung, technische Dokumenttypen, Source Extractors, Mappingprofile, Finding-Identität sowie allgemeine Artefakt-, Processing- und Jobgrenzen sind konsolidiert und die Scannerpersistenz verwendet den Candidate-Pfad.
|
1. Phase 13A ist abgeschlossen: Erkennung, technische Dokumenttypen, Source Extractors, Mappingprofile, Finding-Identität sowie allgemeine Artefakt-, Processing- und Jobgrenzen sind konsolidiert und die Scannerpersistenz verwendet den Candidate-Pfad.
|
||||||
2. Phase 13B ist in Arbeit: Der sichere Ingestion-Unterbau, technische Policy-Identitaeten, versionierte ADMX-/Baselineanalysen, deterministisches Reprocessing, Environment Profiles, konservative Snapshotaggregation, typisierte Evaluation, fail-closed Finding Factory, Unified-Result-Read-Projektion sowie projektgebundene Review- und Orchestrierungsoberflaechen sind umgesetzt. Als naechste vertikale Etappe folgen fachlich kuratierte Policy-Regeln und vollstaendige versionierte Windows-Inbox-ADMX-/ADML-Kataloge; danach aktuelle snapshotbezogene Ergebnisdeduplizierung, weitere Preference-CSEs und Exportdetails. Ohne RSoP/GP Results bleibt die effektive Anwendung weiterhin ausdruecklich nicht verifiziert.
|
2. Phase 13B ist in Arbeit: Der sichere Ingestion-Unterbau, technische Policy-Identitaeten, versionierte ADMX-/Baselineanalysen, deterministisches Reprocessing, Environment Profiles, konservative Snapshotaggregation, typisierte Evaluation, fail-closed Finding Factory, Unified-Result-Read-Projektion, projektgebundene Review-/Orchestrierungsoberflaechen und die aktuelle snapshotbezogene Ergebnisdeduplizierung sind umgesetzt. Als naechste vertikale Etappe folgen fachlich kuratierte Policy-Regeln und vollstaendige versionierte Windows-Inbox-ADMX-/ADML-Kataloge; danach weitere Preference-CSEs und Exportdetails. Ohne RSoP/GP Results bleibt die effektive Anwendung weiterhin ausdruecklich nicht verifiziert.
|
||||||
3. Erst nach verifizierter Phase 13B den kontrollierten Firmenbetrieb aus Phase 14 mit SSO, Rollen, HTTPS, Monitoring, Backup/Restore und Aufbewahrung fortsetzen.
|
3. Erst nach verifizierter Phase 13B den kontrollierten Firmenbetrieb aus Phase 14 mit SSO, Rollen, HTTPS, Monitoring, Backup/Restore und Aufbewahrung fortsetzen.
|
||||||
|
|
||||||
Die technische Reihenfolge ist bis einschliesslich Phase 13A umgesetzt. Phase 13B baut auf dem verifizierten sicheren Ingestion-Unterbau auf und reicht inzwischen produktiv von der begrenzten Parser-/Workerverarbeitung ueber unveraenderliche Analyse-, Profil-, Aggregations- und Evaluationslaeufe bis zur streng evidenzgebundenen Finding Factory und ihrer gemeinsamen Unified-Result-Leseprojektion. Das Backend leitet projektgebundene Aktionsbereitschaft aus aktiven Revisionen und exakt passenden Referenzdaten ab; der Datahub fuehrt Environment-Profile-Anlage, Reprocessing, Aggregation, Evaluation und Finding Factory ueber die autoritativen idempotenten Services aus. Der produktive Regelkatalog bleibt bis fachlicher Kuratierung leer, und die vorhandenen Microsoft-Baselines bleiben ohne vollstaendige Windows-Inbox-ADMX-/ADML-Kataloge partial; dadurch erzeugt dieser reale Bestand weiterhin nur Review und keine Findings.
|
Die technische Reihenfolge ist bis einschliesslich Phase 13A umgesetzt. Phase 13B baut auf dem verifizierten sicheren Ingestion-Unterbau auf und reicht inzwischen produktiv von der begrenzten Parser-/Workerverarbeitung ueber unveraenderliche Analyse-, Profil-, Aggregations- und Evaluationslaeufe bis zur streng evidenzgebundenen Finding Factory und ihrer gemeinsamen Unified-Result-Leseprojektion. Das Backend leitet projektgebundene Aktionsbereitschaft aus aktiven Revisionen und exakt passenden Referenzdaten ab; der Datahub fuehrt Environment-Profile-Anlage, Reprocessing, Aggregation, Evaluation und Finding Factory ueber die autoritativen idempotenten Services aus. Der produktive Regelkatalog bleibt bis fachlicher Kuratierung leer, und die vorhandenen Microsoft-Baselines bleiben ohne vollstaendige Windows-Inbox-ADMX-/ADML-Kataloge partial; dadurch erzeugt dieser reale Bestand weiterhin nur Review und keine Findings.
|
||||||
|
|
@ -1368,14 +1414,39 @@ Aktualisierung nach dem Schritt-9-Vertikalschnitt vom 2026-08-04: Versionierte E
|
||||||
Aktualisierung nach dem Schritt-10/11-Finding-Vertikalschnitt vom 2026-08-04: Finding Factory, persistente Policy-Finding-Occurrences, projektgebundene API und Unified-Result-Read-Projektion sind fuer ausschliesslich vollstaendig belegte `eligible` konfigurierte Abweichungen umgesetzt. Unvollstaendige Evidenz bleibt als Factory-Ablehnung ausserhalb des Resultmodells; Scope und Wirksamkeit bleiben ausdruecklich unbekannt beziehungsweise nicht verifiziert. Der aktive produktive Katalog bleibt bis zur fachlichen Freigabe setting-spezifischer Regeln leer und erzeugt daher weiterhin keine Findings. Als naechster Vertikalschnitt folgt die UI fuer Konflikte, Katalog-/Parserluecken, partielle Analysen und unaufgeloeste Identitaeten; danach folgen fachliche Regelkuratierung und vollstaendige Windows-Inbox-ADMX-Kataloge. Weitere Preference-CSEs bleiben ein getrenntes Arbeitspaket.
|
Aktualisierung nach dem Schritt-10/11-Finding-Vertikalschnitt vom 2026-08-04: Finding Factory, persistente Policy-Finding-Occurrences, projektgebundene API und Unified-Result-Read-Projektion sind fuer ausschliesslich vollstaendig belegte `eligible` konfigurierte Abweichungen umgesetzt. Unvollstaendige Evidenz bleibt als Factory-Ablehnung ausserhalb des Resultmodells; Scope und Wirksamkeit bleiben ausdruecklich unbekannt beziehungsweise nicht verifiziert. Der aktive produktive Katalog bleibt bis zur fachlichen Freigabe setting-spezifischer Regeln leer und erzeugt daher weiterhin keine Findings. Als naechster Vertikalschnitt folgt die UI fuer Konflikte, Katalog-/Parserluecken, partielle Analysen und unaufgeloeste Identitaeten; danach folgen fachliche Regelkuratierung und vollstaendige Windows-Inbox-ADMX-Kataloge. Weitere Preference-CSEs bleiben ein getrenntes Arbeitspaket.
|
||||||
Aktualisierung nach dem Schritt-11-UI-Vertikalschnitt vom 2026-08-04: Konflikte, partielle Analysen, Katalog-/Parserluecken, unaufgeloeste Identitaeten, unbekanntes Targeting und Finding-Factory-Ablehnungen sind nun projektgebunden ueber ein typisiertes Readmodell, eine read-only API und den Datahub sichtbar. Findings und Review bleiben getrennt; Laufhistorie wird nicht als deduplizierter aktueller Kundenzustand bezeichnet, und die fehlende RSoP-/GP-Results-Evidenz bleibt sichtbar. Der unmittelbar folgende Orchestrierungsvertikalschnitt erweitert diese read-only Ansicht um bedienbare Environment-Profile-, Reprocessing-, Aggregations-, Evaluations- und Finding-Factory-Aktionen; fachlich kuratierte Regel- und Windows-Inbox-ADMX-Kataloge, weitere Preference-CSEs und Exportdetails bleiben getrennte Arbeitspakete.
|
Aktualisierung nach dem Schritt-11-UI-Vertikalschnitt vom 2026-08-04: Konflikte, partielle Analysen, Katalog-/Parserluecken, unaufgeloeste Identitaeten, unbekanntes Targeting und Finding-Factory-Ablehnungen sind nun projektgebunden ueber ein typisiertes Readmodell, eine read-only API und den Datahub sichtbar. Findings und Review bleiben getrennt; Laufhistorie wird nicht als deduplizierter aktueller Kundenzustand bezeichnet, und die fehlende RSoP-/GP-Results-Evidenz bleibt sichtbar. Der unmittelbar folgende Orchestrierungsvertikalschnitt erweitert diese read-only Ansicht um bedienbare Environment-Profile-, Reprocessing-, Aggregations-, Evaluations- und Finding-Factory-Aktionen; fachlich kuratierte Regel- und Windows-Inbox-ADMX-Kataloge, weitere Preference-CSEs und Exportdetails bleiben getrennte Arbeitspakete.
|
||||||
Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04: Environment Profiles und ihre Revisionen koennen nun im Datahub angelegt werden; GPO-Reprocessing, Aggregation, Evaluation und Finding Factory sind ueber serverseitig abgeleitete Ready-/Review-only-/Completed-/Blocked-Aktionen bedienbar. Katalogauswahl, aktive Revisionen und technische Quellen werden im Backend-Readmodell bestimmt, waehrend jede Mutation ihre bestehenden Fachinvarianten erneut prueft. Als naechste Vertikalschnitte folgen die fachlich kuratierten Policy-Regeln und vollstaendigen Windows-Inbox-ADMX-Kataloge sowie danach aktuelle snapshotbezogene Ergebnisdeduplizierung, weitere Preference-CSEs und Exportdetails.
|
Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04: Environment Profiles und ihre Revisionen koennen nun im Datahub angelegt werden; GPO-Reprocessing, Aggregation, Evaluation und Finding Factory sind ueber serverseitig abgeleitete Ready-/Review-only-/Completed-/Blocked-Aktionen bedienbar. Katalogauswahl, aktive Revisionen und technische Quellen werden im Backend-Readmodell bestimmt, waehrend jede Mutation ihre bestehenden Fachinvarianten erneut prueft. Als naechste Vertikalschnitte folgen die fachlich kuratierten Policy-Regeln und vollstaendigen Windows-Inbox-ADMX-Kataloge sowie danach aktuelle snapshotbezogene Ergebnisdeduplizierung, weitere Preference-CSEs und Exportdetails.
|
||||||
|
Aktualisierung nach der snapshotbezogenen Ergebnisdeduplizierung vom 2026-09-03: Das Policy-Analyse-Readmodell trennt nun den aktuellen aktiven Snapshotpfad von der weiterhin vollstaendigen Audit-Historie. Summary, Review Queue und ausfuehrbare Folgeaktionen verwenden nur die aktive Revisionskette; historische Evaluationen bleiben separat sichtbar. Als naechste fachliche Etappen verbleiben kuratierte Policy-Regeln und vollstaendige Windows-Inbox-ADMX-/ADML-Kataloge, danach weitere Preference-CSEs und Policy-Exportdetails.
|
||||||
|
Aktualisierung nach der Katalog-Ladegrenze vom 2026-09-03: Fachlich freigegebene Policy-Regeln koennen nun als lokal betriebener, SHA-256-gepinnter und vollstaendig validierter JSON-Katalog reproduzierbar aktiviert werden; der Default bleibt leer und fail-closed. Als naechster fachlicher Schritt sind ein autorisiert kuratierter Regelinhalt und vollstaendige versionierte Windows-Inbox-ADMX-/ADML-Quellen erforderlich; weitere Preference-CSEs und Policy-Exportdetails bleiben danach offen.
|
||||||
|
|
||||||
## 9. Bereits getroffene Architekturentscheidungen
|
## 9. Bereits getroffene Architekturentscheidungen
|
||||||
|
|
||||||
| Datum | Entscheidung | Begründung |
|
| Datum | Entscheidung | Begründung |
|
||||||
|
| 2026-09-03 | Die verbindliche Client-Info-Startansicht expandiert nur `Assessment Status`; alle neun anderen Informationskarten starten minimiert. | Der Gesamtstatus bleibt ohne Interaktion sichtbar, waehrend die darunterliegende feste 3/2/2-Matrix und die rechte Rail als kompakte, stabile Navigation beginnen. Inhalte werden nicht entfernt und bleiben ueber die Standarddreiecke einzeln erreichbar. |
|
||||||
|
| 2026-09-03 | Minimierte Client-Info-Karten bleiben ueber die volle Breite ihrer festen Spalte gespannt. `Directory Overview` zeigt keine werkzeugspezifische PingCastle-Engine-Version mehr. | Collapse soll ausschliesslich die Hoehe reduzieren und darf die stabile Spaltengeometrie nicht veraendern. Die Directory-Karte beschreibt den Verzeichniszustand; eine Scanner-Engine-Version gehoert nicht zu diesem fachlichen Ueberblick und bleibt nur in der unveraenderten Provenienz erhalten. |
|
||||||
|
| 2026-09-03 | Das Client-Info-Hauptraster verwendet drei feste Karten-Spalten mit der dauerhaften Zuordnung 3/2/2 und keine dichte Masonry-Neuverteilung mehr. Collapse veraendert nur die Hoehe innerhalb derselben Spalte. Die Desktop-KPI-Matrix streckt ihre sechs Karten auf die Hoehe von `Assessment Status`; ihre feste Reihenfolge ist Compliance Score, Deviation, High Severity, Passed, Typed Observations, Directory Sections. Nur Deviation und High Severity sind Filteraktionen. | Die ausdrueckliche Nutzeranforderung priorisiert stabile raeumliche Orientierung gegenueber global maximaler Verdichtung. Feste Spalten verhindern Positionswechsel beim Minimieren und Maximieren; die gemeinsame Kopfhoehe beseitigt die grosse Leerstelle. Begrenzte Klickziele vermeiden versehentliche Filterwechsel, waehrend die staerkere Compliance-Hierarchie den wichtigsten Gesamtwert hervorhebt. Diese Entscheidung supersediert die unmittelbar folgende Masonry-Entscheidung. |
|
||||||
|
| 2026-09-03 | Rohe aktuelle GPO-Policy-Beobachtungen werden als eigener Unified-Result-Quelltyp `policy-observation` timeslotgebunden in Result DB und Work dargestellt. Sie sind Kontext-/Reviewdaten mit nicht anwendbaren Finding-Achsen und werden niemals allein durch die Sichtprojektion zu Abweichungen oder Findings. Diese Nutzerkorrektur supersediert fuer die gemeinsame Work-Ansicht die unmittelbar folgende fruehere Beschraenkung von `Source: Scan`: Dort erscheint nun auch `Group Policy`; im Data-Hub bleibt das Backup weiterhin kein auswaehlbarer Scanner-Run. | GPO-Inhalte muessen nach dem Import konkret auffindbar und exportierbar sein, auch solange Identity-, Baseline- oder Risikokataloge noch keine bestaetigte Abweichung erlauben. Die getrennte Quellart schliesst die Sichtbarkeitsluecke, ohne die konservative Finding-Factory-Grenze aufzuweichen. |
|
||||||
|
| 2026-09-03 | GPO-Backups verwenden im Timeslot keine Scanner-Auswahlbox. Eine eigene Quellenmarkierung und `View` oeffnen die projektgebundene Policy-Analyse; `Source: Scan` bleibt ausschliesslich der Provenienzfilter fuer Scanner-Runs. | Ein GPO-Backup ist verarbeitbare Policy-Evidenz, aber kein kanonischer Scanner-Run. Eine deaktivierte Checkbox suggeriert fehlende Berechtigung oder Nichtauswaehlbarkeit, waehrend eine explizite Anzeigeaktion die vorhandenen Analysedaten erreichbar macht, ohne GPO-Evidenz faelschlich in den Scanner-Scope umzudeuten. |
|
||||||
|
| 2026-09-03 | GPO-Backups werden im Data-Hub-Timeslot als eigene Quellen neben Scanner-Reports dargestellt. Ordnerauswahl und vorhandenes ZIP sind getrennte UI-Wege, verwenden aber dieselbe projekt- und timeslotgebundene Backend-Ingestion; nur der Ordnerweg erzeugt zuvor lokal das deterministische Transport-ZIP. | Die bislang bereits gespeicherten GPO-Quellen duerfen nicht unsichtbar bleiben oder als Scanner-Run erscheinen. Zwei eindeutige Eingaben vermeiden Missverstaendnisse, ohne die gehärtete Validierungs-, Speicher- und Jobgrenze zu duplizieren oder aufzuweichen. |
|
||||||
|
| 2026-09-03 | Ein kuratierter Policy-Evaluation-Regelkatalog wird nur aus einer lokalen regulaeren Datei geladen, wenn Pfad und erwarteter SHA-256 gemeinsam konfiguriert sind. Der Hash wird vor Parsing und fachlicher Validierung geprueft; ohne Konfiguration bleibt der eingebettete leere Freigabekatalog aktiv. | Kataloginhalte bestimmen sicherheitsrelevante Abweichungen und Severities. Eine explizite Hashbindung macht den freigegebenen Stand betriebsseitig nachvollziehbar, verhindert stillen Dateidrift und bewahrt den local-only Betrieb ohne automatischen Download oder implizite Aktivierung. |
|
||||||
|
| 2026-09-03 | Das Policy-Analyse-Readmodell fuehrt neben der unveraenderlichen Run-Historie eine explizite aktuelle Sicht. Aktuell sind nur die durch aktive Profil- und Identity-Revisionen erreichbaren Aggregationen mit jeweils ihrer neuesten Evaluation und ihrem neuesten Finding-Run; Summary, Review Queue und Folgeaktionen werden daraus abgeleitet. | Reprocessing darf alte Konflikte, Katalogluecken, Abweichungen und Findings nicht als aktuellen Kundenzustand doppelt zaehlen. Gleichzeitig muss jeder fruehere Lauf fuer Audit und Reproduzierbarkeit erhalten bleiben. Dieselbe Aktivkettenselektion fuer Anzeige und Aktionen verhindert Bedienung auf veralteten Analysestaenden. |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-09-11 | ASSESSOR erweitert den unveraenderten externen `result_type`-Katalog additiv um `vulnerability_finding`. Defender-for-IoT-CVEs erhalten bei fehlendem Quellstatus deklarativ `outcome=warning`; sie sind damit Abweichungen, bleiben aber `is_ioe=false`. `environment-observation` bleibt eine Herkunftsart mit `result_type=informational`. | Eine konkrete CVE auf betroffenen Devices ist weder Compliance-Check noch Policy-Abweichung oder blosse Inventarinformation. `exposure_finding` wuerde die vorhandene Exposure-State-/IOE-Semantik unnoetig auf Schwachstellen ausdehnen. Der eigene Typ trennt Schwachstellenbefund, Dringlichkeit und bestaetigte Exposure weiterhin sauber. |
|
| 2026-09-11 | ASSESSOR erweitert den unveraenderten externen `result_type`-Katalog additiv um `vulnerability_finding`. Defender-for-IoT-CVEs erhalten bei fehlendem Quellstatus deklarativ `outcome=warning`; sie sind damit Abweichungen, bleiben aber `is_ioe=false`. `environment-observation` bleibt eine Herkunftsart mit `result_type=informational`. | Eine konkrete CVE auf betroffenen Devices ist weder Compliance-Check noch Policy-Abweichung oder blosse Inventarinformation. `exposure_finding` wuerde die vorhandene Exposure-State-/IOE-Semantik unnoetig auf Schwachstellen ausdehnen. Der eigene Typ trennt Schwachstellenbefund, Dringlichkeit und bestaetigte Exposure weiterhin sauber. |
|
||||||
| 2026-09-10 | NVD `configurations` bleiben als top-level Konfigurationsgruppen erhalten. Vulnerable CPE-Matches bilden die betroffene Software je Gruppe; nicht vulnerable Matches derselben Gruppe werden getrennt als `Running on/with` gespeichert und dargestellt. Das Enrichment-Recordformat ist dafuer Version 2; Version-1-Flachsaetze bleiben lesbar, muessen fuer die verlustfreie Gruppierung aber erneut von NVD abgerufen werden. | Die NVD-Oberflaeche und API beschreiben eine logische Konfiguration aus betroffener Software und ihren Laufzeit-/Hardwarebedingungen. Ein flaches Match-Array ueberzaehlt Konfigurationen und verliert genau diese Beziehung. Die Gruppierung muss deshalb vor der Persistenz im externen Referenzadapter erhalten werden. |
|
| 2026-09-10 | NVD `configurations` bleiben als top-level Konfigurationsgruppen erhalten. Vulnerable CPE-Matches bilden die betroffene Software je Gruppe; nicht vulnerable Matches derselben Gruppe werden getrennt als `Running on/with` gespeichert und dargestellt. Das Enrichment-Recordformat ist dafuer Version 2; Version-1-Flachsaetze bleiben lesbar, muessen fuer die verlustfreie Gruppierung aber erneut von NVD abgerufen werden. | Die NVD-Oberflaeche und API beschreiben eine logische Konfiguration aus betroffener Software und ihren Laufzeit-/Hardwarebedingungen. Ein flaches Match-Array ueberzaehlt Konfigurationen und verliert genau diese Beziehung. Die Gruppierung muss deshalb vor der Persistenz im externen Referenzadapter erhalten werden. |
|
||||||
| 2026-09-10 | CVE-Enrichment ist ein globaler, nach normalisierter CVE-ID identifizierter Referenzbestand und weder Bestandteil der tenant-/timeslotgebundenen FindingOccurrence noch der werkzeuggebundenen, durch Reimport ersetzbaren FindingDefinition. Defender-Import und NVD-Enrichment bleiben getrennt; nur eine ausdrueckliche Aktion fuer eine bereits gespeicherte Defender-CVE darf den festen NVD-Endpunkt abfragen. | Beschreibung, CVSS-Metriken, CWE, Referenzen und betroffene Konfigurationen beschreiben die CVE selbst. Ein gemeinsamer Datensatz vermeidet redundante, auseinanderlaufende Kopien und macht vorhandene Anreicherung fuer spaetere Mandanten automatisch wiederverwendbar, waehrend der Import ohne Netzabhaengigkeit reproduzierbar bleibt. |
|
| 2026-09-10 | CVE-Enrichment ist ein globaler, nach normalisierter CVE-ID identifizierter Referenzbestand und weder Bestandteil der tenant-/timeslotgebundenen FindingOccurrence noch der werkzeuggebundenen, durch Reimport ersetzbaren FindingDefinition. Defender-Import und NVD-Enrichment bleiben getrennt; nur eine ausdrueckliche Aktion fuer eine bereits gespeicherte Defender-CVE darf den festen NVD-Endpunkt abfragen. | Beschreibung, CVSS-Metriken, CWE, Referenzen und betroffene Konfigurationen beschreiben die CVE selbst. Ein gemeinsamer Datensatz vermeidet redundante, auseinanderlaufende Kopien und macht vorhandene Anreicherung fuer spaetere Mandanten automatisch wiederverwendbar, waehrend der Import ohne Netzabhaengigkeit reproduzierbar bleibt. |
|
||||||
|
| 2026-09-03 | Das Client-Info-Hauptraster verwendet eine Progressive-Enhancement-Masonry-Berechnung auf Basis real gemessener Kartenhoehen. Erst nach erfolgreicher Messung aktiviert es feine 1-px-Tracks und dichten Auto-Flow; ResizeObserver aktualisiert die Spannen bei Collapse, Inhalts- und Breitenwechseln. Zustandsbuttons verwenden das gemeinsame `TriangleIcon` unten/rechts. | Ein regulaeres CSS Grid reserviert pro Zeile die Hoehe der groessten Karte und kann kuerzere geschlossene Karten nicht unabhaengig nach oben packen. Die gemessene Span-Berechnung ermoeglicht echtes Nachruecken, waehrend das vor der Messung aktive Standardraster einen sicheren Fallback ohne Ueberlagerung bildet. Gemeinsame SVG-Dreiecke sind schrift- und kodierungsunabhaengig und entsprechen der bestehenden App-Konvention. |
|
||||||
|
| 2026-09-03 | Die Environment-Fakten `Marked findings`, AD Recycle Bin, Backup/krbtgt, LAPS, WEF und OS bilden eine eigene Client-Info-Kachel `Infrastructure Details`; `Infrastructure & Resilience` bleibt eine reine Mengen-/Risikokarte. Beide verwenden denselben gemeinsamen minimierbaren Kartenvertrag. | Risikoindikatoren und beschreibende Infrastrukturwerte sind zwei unterschiedliche Informationsarten. Eine eigene Kachel bewahrt die luftige Faktengeometrie, verhindert eine ueberlange Mischkarte und laesst beide Gruppen unabhaengig minimieren, ohne ihre gemeinsame Snapshotquelle zu veraendern. |
|
||||||
|
| 2026-09-03 | Maester-`Invoke-MgGraphRequest`-Ausfuehrungsfehler erhalten eine additive strukturierte Praesentation mit Primaermeldung, technischen Schluesselfeldern und standardmaessig eingeklapptem vollstaendigem Rohtext. Die Erkennung und Extraktion liegt in einem reinen werkzeugspezifischen Parser ausserhalb der React-Komponente; nicht erkannte Texte fallen verlustfrei auf die bestehende Rich-Text-Darstellung zurueck. | Lange PowerShell-/HTTP-/JSON-Ketten sind in einer Fliesstextzeile nicht diagnostizierbar. Die Trennung hebt die handlungsrelevante Graph-Ursache hervor, bewahrt Request-Korrelation und Originalbeleg und haelt source-spezifische Interpretation aus der allgemeinen Darstellungskomponente heraus. |
|
||||||
|
| 2026-09-03 | Alle neun Client-Info-Informationskacheln verwenden einen gemeinsamen vollstaendig minimierbaren Interaktionsvertrag: initial geoeffnet mit Minus, minimiert ausschliesslich die Kopfzeile mit Plus und intrinsischer Kopfzeilenhoehe, erneut geoeffnet mit vollstaendigem Inhalt. Die sechs reinen Zahlenkacheln bleiben nicht minimierbar und unveraendert. | Der vorherige Schalter veraenderte bei den sechs Mengen-/Faktenkarten nur optionale Zusatzangaben und fehlte bei Donutkarten teilweise, obwohl die Bedienung als Minimieren verstanden wird. Ein identischer Vertrag fuer beide gemeinsamen Kartenkomponenten plus ein explizites `align-self: start` im minimierten Zustand verhindert wirkungslose Schalter und leere, auf Nachbarhoehe gestreckte Karten. |
|
||||||
|
| 2026-09-03 | Die neueste bildlich bestaetigte Nutzerreferenz ersetzt den unmittelbar zuvor festgelegten minimierten Mengenbalken: Alle neun Client-Info-Infopanels zeigen minimiert nur `Titel — Primaermenge` und Plus. Expandiert verwenden alle mengenbezogenen Inhalte hohe Zeilen mit breitem 72-px-Mengenbalken; Assessment und Severity werden nicht mehr als Donut dargestellt. Faktenzeilen nutzen dieselbe luftige Vertikalordnung. Die sechs Zahlenkacheln bleiben unveraendert. | Der Balken ist Detailinhalt und darf die minimierte Uebersicht nicht erneut verdichten. Eine einheitliche expandierte Zeilengeometrie setzt die Referenz ueber alle Infopanels um, bewahrt reale Werte und vermeidet sowohl erfundene Verlaeufe als auch lokale kompakte Sonderlayouts. |
|
||||||
|
| 2026-09-03 | Die abschliessende Nutzerreferenz supersediert die unmittelbar vorherige reine Collapsed-/Expanded-Geometrie: Sechs Zahlenkacheln stehen oben; sechs Mengenpanels bleiben darunter permanent als dreispaltige Uebersicht sichtbar. Plus/Minus betrifft nur Zusatzdetails. Assessment und Severity bleiben Donut-Kacheln in einer rechten Nebenspalte, Directory Overview folgt dort als Faktenkarte. | Das Zielbild zeigt die Mengenzeilen bereits bei einem Pluszustand und fordert die Donuts ausdruecklich daneben. Eine getrennte Hauptmatrix und Verteilungsspalte bildet diese Hierarchie direkt ab, ohne reale Kennzahlen zu verbergen oder Donutverteilungen in Mengenbalken umzudeuten. |
|
||||||
|
| 2026-09-03 | Die Kopfzone von Client Info verwendet dasselbe 3:1-Verhaeltnis wie Hauptmatrix und rechte Rail: sechs verkleinerte quadratische Kennzahlen links, `Assessment Status` rechts in derselben ersten Reihe. | Der erste Donut soll ohne eine neue Zwischenreihe direkt neben den Zahlenkacheln stehen. Die gemeinsame Spaltenteilung richtet Kopf- und Inhaltszone aus und laesst die verbleibenden Rail-Karten automatisch nachruecken. |
|
||||||
|
| 2026-09-03 | `Open Client` aktiviert nach Uebernahme des Projekt-/Timeslot-Scope standardmaessig den Hauptbereich `Client Info`; `Work` bleibt eine anschliessend bewusst waehlbare Ansicht. | Die Client-Informationsansicht ist der vereinbarte Einstieg in einen geoeffneten Mandanten. Der Scopewechsel und die Wahl des ersten Darstellungsbereichs bleiben getrennte Verantwortlichkeiten. |
|
||||||
|
| 2026-09-03 | Ein expandiertes Client-Info-Infopanel belegt regulaer sechs Rasterspalten und an den Breakpoints die komplette verfuegbare Rasterbreite; minimiert bleibt es zwei Spalten breit. Der Kopf-Primaerwert wird im Body nicht wiederholt; neutrale Mengenserien verwenden die referenzierte ordinale Farbfolge. | Die Referenz besitzt eine breite linke Label-/Wertzone und eine ebenso breite rechte Visualisierungszone. Diese Geometrie ist in einer nur zwei Spalten breiten Karte nicht darstellbar und fuehrt zum sichtbaren Ueberlauf der Mengenbalken. Das Entfernen der ersten Doppelzeile stellt zugleich die Informationshierarchie und die vier sichtbaren Referenzreihen wieder her. |
|
||||||
|
| 2026-09-03 | Die ausdrueckliche AMD-Adrenalin-inspirierte Nutzerreferenz ersetzt den frueheren Quadratvertrag ausschliesslich fuer die neun Client-Info-Detailkarten. Diese starten kompakt mit einer Primaermenge und einem Mengenbalken; Plus/Minus steuert die vollstaendigen bisherigen Details je Karte. Die sechs Zahlenkacheln bleiben unveraendert quadratisch. | Progressive Disclosure erhoeht die Uebersicht, ohne Daten zu entfernen. Mengenbalken stellen den aktuellen Umfang korrekt dar, waehrend erfundene Verlaufsgraphen ohne Zeitreihe eine nicht vorhandene Entwicklung suggerieren wuerden. Die Trennung bewahrt die bestaetigte Zahlenzeile und macht alle real vorhandenen Detailbereiche bedarfsgesteuert erreichbar. |
|
||||||
|
| 2026-09-03 | Die responsive Nutzerkorrektur ersetzt die unmittelbar zuvor festgelegten drei Rasterzeilen: Client-Info-Rasterzeilen verwenden eine breakpointabhaengige Mindestgroesse mit inhaltsbasiertem Auto-Wachstum. Inhaltskarten sind mindestens quadratisch und wachsen bei Bedarf; `Infrastructure & Resilience` besitzt eine automatische Inhaltshoehe. | Eine feste Zahl von Rasterzeilen ist kein responsives Verhalten und kann bei anderer Breite oder Datenmenge erneut zu viel oder zu wenig Platz liefern. Inhaltsbasierte Trackgroessen reagieren sowohl auf den Viewport als auch auf die tatsaechlichen Werte, ohne Clipping oder starre Leerflaeche. |
|
||||||
|
| 2026-09-03 | Die inhaltsreiche Dashboardkachel `Infrastructure & Resilience` belegt drei Rasterzeilen und hebt ausschliesslich fuer sich das starre Quadratverhaeltnis auf; alle anderen Client-Info-Karten behalten die gemeinsame quadratische Geometrie. | Die vollstaendige dynamische OS-Liste darf weder abgeschnitten werden noch aus der Kachel herauslaufen. Eine gezielte Hoehenerweiterung bewahrt die bestehende Faktenstruktur und den dichten Gesamtrastervertrag, ohne andere Karten unnoetig zu vergroessern. |
|
||||||
|
| 2026-09-03 | Regulaere Work-Finding-Kacheln besitzen eine feste Inhaltsbreite von 358 px und eine Mindesthoehe von 223 px; sie duerfen fuer vollstaendigen Inhalt nach unten wachsen. Die Metadatenreihenfolge ist Status, Kategorie, Severity/Weight und Score. Pathfinder-Micro-Cards behalten ihre eigenstaendige kompakte Geometrie. | Das freigegebene Referenzformat muss die untere Groessengrenze jeder Matrixkachel bilden. Eine feste Breite verhindert das Aufziehen der letzten Reihe; eine Mindest- statt Fixhoehe bewahrt lange Titel und Metadaten ohne Clipping. Die semantische Badge-Matrix erzeugt unabhaengig von der Datensatzkombination dieselbe Lesereihenfolge. |
|
||||||
|
| 2026-09-03 | Die Result DB verwendet `Deviation = Yes` als initialen und durch `Reset filters` wiederhergestellten Arbeitszustand. Ihr LLM-Export ist versioniertes JSONL: ein Manifest mit aktivem Timeslot-/Projekt-Scope und allen Nutzerfiltern, danach eine Zeile pro vollstaendigem Unified Result. | Abweichungen sind die primaere Analysemenge. JSONL bewahrt verschachtelte Evidenz und Provenienz, bleibt an Datensatzgrenzen teilbar und ist fuer LLM- sowie maschinelle Weiterverarbeitung robuster als eine flache CSV- oder unstrukturierte Markdown-Ausgabe. Der Manifest-Snapshot macht die exportierte Auswahl reproduzierbar. |
|
||||||
|
| 2026-09-03 | Balkenlisten in den quadratischen Client-Info-Karten verwenden dieselben drei Spalten fuer Label, Track und Wert sowie eine stets reservierte zweite Wertzeile fuer optionale Quellenhinweise. Zahlen und Einheiten bleiben in einer nicht umbrechenden Wertgruppe; die Resilience-Kompaktkarte ueberschreibt diesen Vertrag lokal mit ihrer bestehenden Einspaltenanordnung. | Eine optionale Zusatzzeile darf weder die Hoehe einzelner Eintraege noch Track- oder Wertbreiten zwischen gleichartigen Karten veraendern. Gemeinsame Spaltenrollen machen die Werte visuell vergleichbar, ohne Daten oder Kartenstruktur umzudeuten. |
|
||||||
| 2026-08-26 | Ein Policy-Evaluation-Regelkatalog darf den Status `released` nur tragen, wenn seine Runtime-Vertraege geschlossen validiert sind und fuer dieselbe technische Identitaet, denselben Auslieferungsmechanismus und ueberlappende Produkt-/Versions-/Rollen-/Scope-Kontexte hoechstens eine Regel aufloesbar ist. Wiederverwendete Risiko- und Abhaengigkeits-IDs muessen katalogweit dieselbe Definition besitzen. | Der kuenftige fachlich kuratierte Katalog wird an einer sicherheitsrelevanten Finding-Grenze ausgefuehrt. TypeScript-Typen allein schuetzen weder geladene noch historisch persistierte Runtime-Daten; Mehrdeutigkeit oder unbekannte Enumwerte muessen deshalb vor jeder Freigabe fail-closed blockieren. |
|
| 2026-08-26 | Ein Policy-Evaluation-Regelkatalog darf den Status `released` nur tragen, wenn seine Runtime-Vertraege geschlossen validiert sind und fuer dieselbe technische Identitaet, denselben Auslieferungsmechanismus und ueberlappende Produkt-/Versions-/Rollen-/Scope-Kontexte hoechstens eine Regel aufloesbar ist. Wiederverwendete Risiko- und Abhaengigkeits-IDs muessen katalogweit dieselbe Definition besitzen. | Der kuenftige fachlich kuratierte Katalog wird an einer sicherheitsrelevanten Finding-Grenze ausgefuehrt. TypeScript-Typen allein schuetzen weder geladene noch historisch persistierte Runtime-Daten; Mehrdeutigkeit oder unbekannte Enumwerte muessen deshalb vor jeder Freigabe fail-closed blockieren. |
|
||||||
| 2026-08-25 | Die appweite persistente Markierungs- und Auswahlfarbe ist auf ausdrueckliche Nutzerfestlegung immer Gelb und wird in beiden Themes ueber `--marking-color` gefuehrt. Kachelauswahl, Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl verwenden diesen Token. Blau bleibt Tastaturfokus; Gruen, Rot, Orange und Severitytoene bleiben fachliche Statussignale. | Ein einzelner semantischer Markierungston verhindert violette oder skinabhaengig wechselnde Auswahlkonturen wie im Datahub-Screenshot und bewahrt gleichzeitig die Bedeutung bestehender Statusfarben. |
|
| 2026-08-25 | Die appweite persistente Markierungs- und Auswahlfarbe ist auf ausdrueckliche Nutzerfestlegung immer Gelb und wird in beiden Themes ueber `--marking-color` gefuehrt. Kachelauswahl, Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl verwenden diesen Token. Blau bleibt Tastaturfokus; Gruen, Rot, Orange und Severitytoene bleiben fachliche Statussignale. | Ein einzelner semantischer Markierungston verhindert violette oder skinabhaengig wechselnde Auswahlkonturen wie im Datahub-Screenshot und bewahrt gleichzeitig die Bedeutung bestehender Statusfarben. |
|
||||||
| 2026-08-25 | Die unmittelbar zuvor fuer Client Info festgelegte flache SOC-Kachel ist auf ausdrueckliche Nutzerfestlegung der neue appweite Kachelstandard: keine sichtbare Normalzustandskontur, kein Radius und kein Schatten; Hover veraendert nur die Flaeche. Fokus, Auswahl und kleine Statusakzente bleiben funktionale Ausnahmen. Die gemeinsamen `--tile-*`-Tokens und Projektregeln tragen diesen Vertrag. | Ein zentraler Standard verhindert, dass neue oder bestehende Ansichten erneut weisse Konturen, Rundungen oder schwebende Schatten einfuehren. Flaechenkontrast und gemeinsamer Abstand erzeugen die gewuenschte ruhige, dichte SOC-Geometrie. |
|
| 2026-08-25 | Die unmittelbar zuvor fuer Client Info festgelegte flache SOC-Kachel ist auf ausdrueckliche Nutzerfestlegung der neue appweite Kachelstandard: keine sichtbare Normalzustandskontur, kein Radius und kein Schatten; Hover veraendert nur die Flaeche. Fokus, Auswahl und kleine Statusakzente bleiben funktionale Ausnahmen. Die gemeinsamen `--tile-*`-Tokens und Projektregeln tragen diesen Vertrag. | Ein zentraler Standard verhindert, dass neue oder bestehende Ansichten erneut weisse Konturen, Rundungen oder schwebende Schatten einfuehren. Flaechenkontrast und gemeinsamer Abstand erzeugen die gewuenschte ruhige, dichte SOC-Geometrie. |
|
||||||
|
|
@ -1644,11 +1715,31 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
## 11. Risiken
|
## 11. Risiken
|
||||||
|
|
||||||
| Risiko | Gegenmaßnahme |
|
| Risiko | Gegenmaßnahme |
|
||||||
|
| Eine spaetere Kartenrefaktorierung kann wieder alle Client-Info-Karten standardmaessig oeffnen und damit die vereinbarte kompakte Startansicht verlieren. | Den Startzustand an jeder konkreten Karteninstanz explizit setzen und statisch pruefen: neun `initiallyExpanded={false}` sowie genau ein nicht minimierter `Assessment Status`; die gemeinsame Komponente bleibt fuer gezielte Wiederverwendung konfigurierbar. |
|
||||||
|
| Eine minimierte Client-Info-Karte kann durch intrinsische Flexbreite schmaler als ihre Spalte werden; werkzeugspezifische Provenienz kann ausserdem erneut in einen fachlichen Verzeichnisueberblick geraten. | Collapsed-Karten explizit auf `width: 100%` setzen und diese Geometrie regressionspruefen. `directoryFacts` auf fachliche Directory-Felder begrenzen; Scanner-Engine-Versionen bleiben in der Quellprovenienz statt in `Directory Overview`. |
|
||||||
|
| Client-Info-Karten koennen beim Ein-/Ausklappen zwischen Spalten wechseln und dadurch die erlernte Position verlieren; unterschiedlich hohe Kopfbereiche koennen ausserdem eine grosse Leerstelle vor der Inhaltsmatrix erzeugen. | Karten statisch in drei semantische Spalten mit der Zuordnung 3/2/2 gruppieren und innerhalb jeder Spalte per Flex-Stack anordnen. Keine dichte Auto-Platzierung oder laufzeitbasierte Spannenberechnung verwenden. KPI-Matrix und Assessment-Status in derselben gestreckten Kopfzeile fuehren; an schmalen Breakpoints kontrolliert stapeln. Spaltenzahl, Karteninstanzen, Reihenfolge und Collapse regressionspruefen. |
|
||||||
|
| Ein lokal bereitgestellter Policy-Regelkatalog kann nach fachlicher Freigabe unbemerkt veraendert, nur teilweise konfiguriert, als Symlink umgebogen oder mit ungueltigen Runtime-Strukturen geladen werden und dadurch andere Findings erzeugen. | Pfad und SHA-256 als untrennbares Konfigurationspaar verlangen, nur regulaere Nicht-Symlink-Dateien bis 5 MiB lesen, Hash vor UTF-8-/JSON-Parsing vergleichen und danach den geschlossenen Katalogvertrag fail-closed validieren. Bei jedem Fehler vor Repository- und Serverstart abbrechen; Evaluation Runs bewahren weiterhin den vollstaendigen tatsaechlich verwendeten Katalog. |
|
||||||
|
| Historische GPO-Reprocessing-Ketten koennen alte Konflikte, Reviewhinweise, Abweichungen und Findings mehrfach als aktuellen Zustand erscheinen lassen; wenn UI und Aktionsableitung unterschiedliche Auswahlregeln verwenden, bleiben ausserdem veraltete Evaluationen bedienbar. | Aktive Profile und nicht ersetzte Identity-Analysen je Artefakt als Wurzel einer gemeinsamen deterministischen Aktivkette verwenden; nur dazu passende Aggregationen, jeweils neueste Evaluationen und Finding-Runs in die aktuelle Sicht aufnehmen. Historie getrennt erhalten und dieselbe Auswahlfunktion fuer Summary, Review Queue sowie Evaluate-/Finding-Aktionen regressionspruefen. |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Alte Defender-Importe mit Profil 1.0.0 tragen im gespeicherten Occurrence-JSON weiterhin `outcome=unknown`; wenn Work bereits die aktuelle Profilregel verwendet, koennten Projektion und SQL-Vorfilterung widerspruechlich werden. | Profil 1.1.0 als semantisch inkompatiblen Vertrag mit neuem Compatibility Key registrieren. Die unveraenderliche Migration 022 erweitert den Result-Type-Constraint; Migration 023 zieht ausschliesslich die abgeleiteten relationalen Read-Model-Spalten `result_type` und `outcome_normalized` fuer bestehende Defender-Vorkommen nach. Originalfelder, Occurrence-JSON und Profilprovenienz bleiben unveraendert. Die Leseprojektion behandelt einen nachweislich statuslosen historischen Vulnerability-Fund ebenso. Reimport/Reprocessing bleibt fuer einen vollstaendig aktuellen kanonischen Datensatz erforderlich. |
|
| Alte Defender-Importe mit Profil 1.0.0 tragen im gespeicherten Occurrence-JSON weiterhin `outcome=unknown`; wenn Work bereits die aktuelle Profilregel verwendet, koennten Projektion und SQL-Vorfilterung widerspruechlich werden. | Profil 1.1.0 als semantisch inkompatiblen Vertrag mit neuem Compatibility Key registrieren. Die unveraenderliche Migration 022 erweitert den Result-Type-Constraint; Migration 023 zieht ausschliesslich die abgeleiteten relationalen Read-Model-Spalten `result_type` und `outcome_normalized` fuer bestehende Defender-Vorkommen nach. Originalfelder, Occurrence-JSON und Profilprovenienz bleiben unveraendert. Die Leseprojektion behandelt einen nachweislich statuslosen historischen Vulnerability-Fund ebenso. Reimport/Reprocessing bleibt fuer einen vollstaendig aktuellen kanonischen Datensatz erforderlich. |
|
||||||
| NVD kann nicht erreichbar oder rate-limitiert sein, unerwartet grosse Antworten liefern oder externe Referenz-URLs enthalten; ein API-Schluessel darf nicht an Browser oder Logs gelangen. Eine automatische Anreicherung waehrend des Imports wuerde zudem dessen Reproduzierbarkeit und Verfuegbarkeit an einen externen Dienst koppeln. | Enrichment ausschliesslich separat und nutzerausgeloest im Backend ausfuehren, nur exakt gespeicherte Defender-CVE-IDs akzeptieren, Zielorigin und API-Pfad fest verdrahten sowie Timeout und 8-MiB-Antwortgrenze erzwingen. Den optionalen Schluessel nur aus `NVD_API_KEY` als Serverheader senden, Antwortfelder normalisieren, Referenzen nur als HTTP(S)-Links darstellen und Importfehler strikt vom Enrichment-Ergebnis trennen. |
|
| NVD kann nicht erreichbar oder rate-limitiert sein, unerwartet grosse Antworten liefern oder externe Referenz-URLs enthalten; ein API-Schluessel darf nicht an Browser oder Logs gelangen. Eine automatische Anreicherung waehrend des Imports wuerde zudem dessen Reproduzierbarkeit und Verfuegbarkeit an einen externen Dienst koppeln. | Enrichment ausschliesslich separat und nutzerausgeloest im Backend ausfuehren, nur exakt gespeicherte Defender-CVE-IDs akzeptieren, Zielorigin und API-Pfad fest verdrahten sowie Timeout und 8-MiB-Antwortgrenze erzwingen. Den optionalen Schluessel nur aus `NVD_API_KEY` als Serverheader senden, Antwortfelder normalisieren, Referenzen nur als HTTP(S)-Links darstellen und Importfehler strikt vom Enrichment-Ergebnis trennen. |
|
||||||
| Defender-for-IoT-Summarys koennen Severity-Zahlen ohne einzelne CVEs enthalten; wiederholte Sensorzeilen koennen dieselbe Device-CVE-Beziehung mehrfach melden, und pseudonymisierte oder wiederverwendete Adressen koennen Identitaeten mehrdeutig machen. | Aggregate ohne Detail niemals in Findings umwandeln, Device-mal-CVE vor der kanonischen Projektion deduplizieren, alle Sensorzeilen als Evidenz erhalten, Identitaet nach der dokumentierten MAC-/IP-/Name-Site-Reihenfolge bilden und Konflikte beziehungsweise Reconciliation-Abweichungen diagnostizieren statt still zusammenzufuehren. |
|
| Defender-for-IoT-Summarys koennen Severity-Zahlen ohne einzelne CVEs enthalten; wiederholte Sensorzeilen koennen dieselbe Device-CVE-Beziehung mehrfach melden, und pseudonymisierte oder wiederverwendete Adressen koennen Identitaeten mehrdeutig machen. | Aggregate ohne Detail niemals in Findings umwandeln, Device-mal-CVE vor der kanonischen Projektion deduplizieren, alle Sensorzeilen als Evidenz erhalten, Identitaet nach der dokumentierten MAC-/IP-/Name-Site-Reihenfolge bilden und Konflikte beziehungsweise Reconciliation-Abweichungen diagnostizieren statt still zusammenzufuehren. |
|
||||||
| Ein gemischtes All-Records-Blatt kann den generischen Titel `Name` auf alle Scanner anwenden und dadurch bei Defender die Device-IP statt der CVE anzeigen; zugleich kann eine rein profilgebundene Client-Info-Sonderansicht in gemischten Timeslots verschwinden. | Titel im gemischten Blatt je Datensatz aus dessen `Source: Scan`-Provenienz und deklarativem Werkzeugprofil bestimmen. Die allgemeine Client-Info-Uebersicht beibehalten und die Defender-Projektion bei vorhandenen Defender-Quellblaettern als eigenen Tab anbieten. |
|
| Ein gemischtes All-Records-Blatt kann den generischen Titel `Name` auf alle Scanner anwenden und dadurch bei Defender die Device-IP statt der CVE anzeigen; zugleich kann eine rein profilgebundene Client-Info-Sonderansicht in gemischten Timeslots verschwinden. | Titel im gemischten Blatt je Datensatz aus dessen `Source: Scan`-Provenienz und deklarativem Werkzeugprofil bestimmen. Die allgemeine Client-Info-Uebersicht beibehalten und die Defender-Projektion bei vorhandenen Defender-Quellblaettern als eigenen Tab anbieten. |
|
||||||
|
| Unterschiedlich hohe oder geschlossene Client-Info-Karten koennen in einem regulaeren Zeilenraster grosse Leerraeume hinterlassen; eine unbedingte JS-Masonry-Aktivierung koennte ohne Mess-API dagegen Karten ueberlagern. | Mit dem normalen Raster starten, Kartenhoehen inklusive gemeinsamem Segmentabstand messen und erst danach 1-px-Spannen mit `grid-auto-flow: row dense` aktivieren. Raster und jede Karte beobachten, nur geaenderte Spannen schreiben und bei fehlendem ResizeObserver im sicheren Standardraster bleiben. Synthetische DOM-Hoehen und Collapse werden regressionsgeprueft. |
|
||||||
|
| Infrastruktur-Fakten koennten bei einer spaeteren Dashboardverdichtung erneut mit Risiko-Mengenwerten in einer ueberlangen Mischkarte zusammengefuehrt oder zwischen beiden Karten doppelt dargestellt werden. | `Infrastructure & Resilience` ausschliesslich mit `risk.slice(1)` speisen und den vollstaendigen Faktenblock genau einmal in `Infrastructure Details` rendern. Instanzzahl, beide Titel, Trennung und vollstaendige Snapshot-Faktenliste statisch regressionspruefen; beide Karten ueber den gemeinsamen Collapse-Test absichern. |
|
||||||
|
| Maester-Fehlertexte koennen PowerShell-Kontext, HTTP-Antwort und verschachteltes JSON in einer langen Zeile mischen; eine aggressive Umformatierung koennte dabei Diagnosewerte verlieren oder normale Ergebnisprosa faelschlich als Fehler behandeln. | Nur die eindeutige Kombination aus Maester, Ausfuehrungsfehlerformulierung und `Invoke-MgGraphRequest` strukturiert parsen. Die Primaermeldung und bekannte technische Felder additiv extrahieren, den unveraenderten Rohtext stets einklappbar erhalten und bei jeder Nichterkennung auf den generischen Renderer zurueckfallen. Parser und Darstellung mit synthetischen Fehlern regressionspruefen. |
|
||||||
|
| Eine Client-Info-Kachel koennte einen Plus-/Minus-Schalter anzeigen, ohne ihren gesamten Inhalt zu minimieren, in einer hohen Rasterzeile als leere Vollhoehenkarte stehen bleiben oder zwischen Donut- und Faktenkarten unterschiedliche Zustandslogik verwenden. | Beide gemeinsamen Informationskartenkomponenten rendern im minimierten Zustand strukturell nur die Kopfzeile, verwenden dort intrinsische statt gestreckter Rasterhoehe und binden denselben `aria-expanded`-/`aria-controls`-Schalter an den vollstaendigen Inhaltscontainer. Interaktionstests decken beide Kartentypen ab; der Layouttest prueft alle neun konkreten Instanzen, Titel und die Collapsed-Geometrie. |
|
||||||
|
| Eine feste Rasterspan-Anzahl kann bei wechselnder Dashboardbreite und unterschiedlich langen Environment-Werten erneut Leerraum oder Ueberlauf erzeugen; rein automatische Tracks ohne Mindestmass koennten dagegen die quadratische Grundgeometrie aufloesen. | Die responsive Spaltenzahl weiterhin in eine gemeinsame Mindest-Rastereinheit uebersetzen, `grid-auto-rows` jedoch als `minmax(Mindestmass, auto)` definieren. Inhaltskarten erhalten `min-content` als Untergrenze; nur die datenreiche Resilience-Kachel hebt ihr bevorzugtes Quadratverhaeltnis auf. |
|
||||||
|
| Client-Info-Infopanels koennten in minimierter Ansicht erneut Balken oder weitere Inhalte zeigen, expandiert zu kompakte Zeilen verwenden oder fuer Assessment und Severity wieder auf eine abweichende Diagrammgeometrie zurueckfallen. | Minimiert strukturell nur Kopfzeile und Plus rendern. Den gemeinsamen expandierten Mengenvertrag mit 120-px-Zeilen, 72-px-Tracks, responsive gestapelter Schmalansicht und vollstaendig wachsenden Fakten anwenden. Alle neun Panelinstanzen sowie die Abwesenheit minimierter Balken und Client-Info-Donuts regressionspruefen. |
|
||||||
|
| Eine spaetere Progressive-Disclosure-Aenderung koennte die im Zielbild permanent sichtbaren Mengenzeilen erneut ausblenden oder die beiden Verteilungen aus der rechten Donutspalte entfernen. | Kerninhalt und optionale Zusatzdetails als getrennte DOM-Bereiche rendern; nur den Zusatzbereich an `aria-expanded` binden. Hauptmatrix, rechte Rail, zwei Donutkarten und sichtbaren Kerninhalt im Pluszustand gemeinsam regressionspruefen. |
|
||||||
|
| Eine getrennte volle Kennzahlenzeile koennte `Assessment Status` erneut in eine zweite Reihe zwingen oder bei schmalen Viewports zu kleine quadratische Zahlenkacheln erzeugen. | Kopfzone als 3:1-Raster aus Zahlenmatrix und Assessment-Donut aufbauen; bei 1100 px stapeln und die Zahlenmatrix danach auf drei beziehungsweise zwei Spalten reduzieren. Vorhandene Quadrat- und Kachelvertraege regressionspruefen. |
|
||||||
|
| Der Client-Open-Ablauf koennte bei spaeteren Data-Hub-Aenderungen wieder implizit in Work landen oder den gewaehlten Timeslot-Scope verlieren. | Zielbereich und `openProject(project.id, scanRunIds)` gemeinsam am zentralen `onProjectSelected`-Callback halten und durch eine Quellregression auf `dashboard` sowie die unveraenderte ScanRun-Uebergabe absichern. |
|
||||||
|
| Ein expandiertes Infopanel koennte trotz breiter interner Balkenspalte auf der kompakten Zwei-Spalten-Kartenbreite verbleiben; Inhalte wuerden dann aus der Kachel herausragen und Nachbarkacheln ueberdecken. | Expandierte Karten auf sechs Rasterspalten setzen und an 800-/560-px-Breakpoints auf die jeweils gesamte Rasterbreite reduzieren. Desktop- und Breakpoint-Spans statisch regressionspruefen; minimierte Kartenbreite nicht veraendern. |
|
||||||
|
| Progressive Detailkarten koennten Informationen versehentlich dauerhaft verbergen, unzugaengliche Symbolschalter verwenden oder mit erfundenen Zeitreihen eine nicht vorhandene Entwicklung suggerieren. Unterschiedliche expandierte Inhaltshoehen koennten erneut ueberlappen. | Jede Karte behaelt eine sichtbare Primaermenge mit echtem proportionalem Mengenbalken und rendert den unveraenderten vollstaendigen Inhalt ueber einen beschrifteten `aria-expanded`-/`aria-controls`-Schalter. Keine Sparklines ohne historische Daten erzeugen. Inhaltsbasierte Rastertracks und eine echte Klickregression sichern Ein-/Ausblenden, Balkenwert und ueberlauffreies Wachstum. |
|
||||||
|
| Dynamische OS-Aufzaehlungen koennen die feste 2-x-2-Quadratflaeche der Infrastructure-&-Resilience-Kachel ueberschreiten und darunterliegende Dashboardbereiche ueberdecken; eine globale Kartenvergroesserung wuerde dagegen das gesamte dichte Raster aufblaehen. | Nur die betroffene inhaltsreiche Kachel drei Rasterzeilen belegen und dort `aspect-ratio` aufheben. Vollstaendige Werte und normale Wortumbrueche beibehalten; die Ausnahme sowie die unveraenderte Faktenmatrix statisch regressionspruefen. |
|
||||||
|
| Feste Finding-Kachelbreiten koennen an schmalen Viewports weniger Spalten ergeben; lange Titel oder zusaetzliche Kategorien koennten bei einer zugleich festgeschriebenen Hoehe abgeschnitten werden. Eine globale Badge-Regel koennte ausserdem kompakte Pathfinder-Karten unbeabsichtigt vergroessern. | Nur die Breite und Mindesthoehe der regulaeren Work-Kachel zentral festschreiben, Zeilenhoehen fuer Virtualisierung daraus ableiten und Inhalt nach unten wachsen lassen. Die semantische Badge-Matrix strikt auf direkte Work-Rasterkacheln begrenzen; Pathfinder bleibt durch fokussierte Regressionen separat abgesichert. |
|
||||||
|
| Ein Result-DB-Export koennte nur die aktuell geladenen 50 Zeilen statt aller Filtertreffer enthalten oder bei paralleler Datenmutation Seiten aus unterschiedlichen Ergebnisstaenden vermischen. | Exportseiten mit identischem projekt-/timeslotgebundenem Filterquery deterministisch nachladen, die erwartete Gesamtzahl ueber alle Seiten konstant pruefen und bei Aenderung oder vorzeitig leerer Seite ohne Download abbrechen. Manifest und Ergebnisanzahl gemeinsam regressionspruefen. |
|
||||||
|
| Optionale Statushinweise oder unterschiedlich lange Zahlen-/Einheitentexte koennen die Balkenbreite und Zeilenhoehe einzelner Client-Info-Karten erneut verschieben; globale Balkenregeln koennen zugleich die kompakte Resilience-Karte ueberlagern. | Statusplatz in jeder Standardzeile reservieren, Label/Track/Wert ueber feste semantische Gridspalten ausrichten und Zahlen samt Einheit ohne Umbruch gruppieren. Die Resilience-Ausnahme setzt Zeilen und Platzierung explizit zurueck; beide Geometrien werden gemeinsam regressionsgeprueft. |
|
||||||
| Ein formal als `released` markierter Policy-Regelkatalog enthaelt unbekannte Runtime-Enumwerte, ueberlappende Kontextselektoren, mehrdeutige Regeln oder widerspruechlich wiederverwendete Risiko-/Abhaengigkeits-IDs und erzeugt dadurch nicht reproduzierbare beziehungsweise fachlich falsche Evaluationen oder Findings. | Die zentrale Katalogvalidierung prueft Auslieferungsmechanismen, Dispositionen, Comparatorvarianten und -modi, Severity, Kontextueberlappung sowie katalogweite ID-Definitionen geschlossen und fail-closed. Jede spaetere fachliche Katalogerweiterung benoetigt positive und negative synthetische Freigaberegressionen; der produktive Katalog bleibt bis zur fachlichen Kuratierung leer. |
|
| Ein formal als `released` markierter Policy-Regelkatalog enthaelt unbekannte Runtime-Enumwerte, ueberlappende Kontextselektoren, mehrdeutige Regeln oder widerspruechlich wiederverwendete Risiko-/Abhaengigkeits-IDs und erzeugt dadurch nicht reproduzierbare beziehungsweise fachlich falsche Evaluationen oder Findings. | Die zentrale Katalogvalidierung prueft Auslieferungsmechanismen, Dispositionen, Comparatorvarianten und -modi, Severity, Kontextueberlappung sowie katalogweite ID-Definitionen geschlossen und fail-closed. Jede spaetere fachliche Katalogerweiterung benoetigt positive und negative synthetische Freigaberegressionen; der produktive Katalog bleibt bis zur fachlichen Kuratierung leer. |
|
||||||
| Auswahl, Fokus und fachlicher Status koennen erneut farblich vermischt werden, wenn Komponenten direkt Akzent-, Fokus- oder Severitytokens fuer eine persistente Markierung verwenden. | Persistente Auswahl ausschliesslich an `--marking-color` binden und in beiden Themes auf denselben Gelbwert regressionspruefen. `--focus-ring` bleibt nur fuer Tastaturfokus; Online-, Fehler-, Workflow- und Severityfarben bleiben fachlich getrennt. |
|
| Auswahl, Fokus und fachlicher Status koennen erneut farblich vermischt werden, wenn Komponenten direkt Akzent-, Fokus- oder Severitytokens fuer eine persistente Markierung verwenden. | Persistente Auswahl ausschliesslich an `--marking-color` binden und in beiden Themes auf denselben Gelbwert regressionspruefen. `--focus-ring` bleibt nur fuer Tastaturfokus; Online-, Fehler-, Workflow- und Severityfarben bleiben fachlich getrennt. |
|
||||||
| Einzelansichten koennten trotz flacher gemeinsamer Tokens im Hover erneut dekorative Konturen setzen oder den Standard mit lokalen Radien und Schatten umgehen. | Appweite Projektregel und Regression an die flachen `--tile-*`-Tokens binden; normale und Hover-Zustaende getrennt von Fokus, Auswahl und Statusakzenten pruefen. Lokale Kachelgeometrie bleibt unzulaessig. |
|
| Einzelansichten koennten trotz flacher gemeinsamer Tokens im Hover erneut dekorative Konturen setzen oder den Standard mit lokalen Radien und Schatten umgehen. | Appweite Projektregel und Regression an die flachen `--tile-*`-Tokens binden; normale und Hover-Zustaende getrennt von Fokus, Auswahl und Statusakzenten pruefen. Lokale Kachelgeometrie bleibt unzulaessig. |
|
||||||
|
|
@ -1857,6 +1948,29 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
| 2026-09-10 | Phasen 7/13A fuer gemischte Defender-Timeslots korrigiert: Work bestimmt den Titel jedes Datensatzes anhand seiner Scannerprovenienz und des deklarativen Titel-Headers, sodass Defender-Vorkommen im gemeinsamen All-Records-Blatt die CVE statt Device-Name oder IP zeigen. Client Info behaelt bei mehreren Scannern die allgemeine Uebersicht und bietet Defender zusaetzlich als eigenen Tab mit den vorhandenen CVE-, Device- und Site-Sichten an. 19 fokussierte Dashboard-, Titel- und Graphregressionen erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phasen 7 und 13A bleiben abgeschlossen. |
|
| 2026-09-10 | Phasen 7/13A fuer gemischte Defender-Timeslots korrigiert: Work bestimmt den Titel jedes Datensatzes anhand seiner Scannerprovenienz und des deklarativen Titel-Headers, sodass Defender-Vorkommen im gemeinsamen All-Records-Blatt die CVE statt Device-Name oder IP zeigen. Client Info behaelt bei mehreren Scannern die allgemeine Uebersicht und bietet Defender zusaetzlich als eigenen Tab mit den vorhandenen CVE-, Device- und Site-Sichten an. 19 fokussierte Dashboard-, Titel- und Graphregressionen erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phasen 7 und 13A bleiben abgeschlossen. |
|
||||||
| 2026-09-10 | Phase-13A-Verarbeitungsvertrag fuer Defender-Importe korrigiert: Die Kompatibilitaetspruefung verwendet nun den tatsaechlich persistierten `processedWith.profileId` statt einer eventuell abweichenden erkannten Werkzeug-ID. Dadurch werden registrierte Defender-Profile 1.0.0 als aktuell bewertet; unbekannte Profil-/Versionspaare bleiben fail closed. Die Korrektur ist read-only fuer vorhandene Importdaten und benoetigt keinen Reimport oder Datenbankeingriff. 17 fokussierte Import-/Kompatibilitaetstests sowie der Gesamtlauf mit 766 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich; Phase 13A bleibt abgeschlossen. |
|
| 2026-09-10 | Phase-13A-Verarbeitungsvertrag fuer Defender-Importe korrigiert: Die Kompatibilitaetspruefung verwendet nun den tatsaechlich persistierten `processedWith.profileId` statt einer eventuell abweichenden erkannten Werkzeug-ID. Dadurch werden registrierte Defender-Profile 1.0.0 als aktuell bewertet; unbekannte Profil-/Versionspaare bleiben fail closed. Die Korrektur ist read-only fuer vorhandene Importdaten und benoetigt keinen Reimport oder Datenbankeingriff. 17 fokussierte Import-/Kompatibilitaetstests sowie der Gesamtlauf mit 766 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich; Phase 13A bleibt abgeschlossen. |
|
||||||
| 2026-09-10 | Microsoft Defender for IoT vulnerability imports and views umgesetzt: Profil und Source Extractor 1.0.0 erweitern die generische Excel-/CSV-Pipeline; Device-Identitaeten, CVE-Definitionen, Device-mal-CVE-Vorkommen, rohe Sensorbeobachtungen und Aggregate-only-Zahlen bleiben getrennt. Client Info bietet CVE-, Device- und Site-Sichten mit Evidenz- und Reconciliation-Drill-down. Summary-only-Quellen bleiben ohne erfundene Findings viewable. 33 fokussierte synthetische Tests sowie 764 Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phasen 6, 7 und 13A bleiben abgeschlossen. |
|
| 2026-09-10 | Microsoft Defender for IoT vulnerability imports and views umgesetzt: Profil und Source Extractor 1.0.0 erweitern die generische Excel-/CSV-Pipeline; Device-Identitaeten, CVE-Definitionen, Device-mal-CVE-Vorkommen, rohe Sensorbeobachtungen und Aggregate-only-Zahlen bleiben getrennt. Client Info bietet CVE-, Device- und Site-Sichten mit Evidenz- und Reconciliation-Drill-down. Summary-only-Quellen bleiben ohne erfundene Findings viewable. 33 fokussierte synthetische Tests sowie 764 Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phasen 6, 7 und 13A bleiben abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Standardansicht nach Nutzerreferenz festgelegt: `Assessment Status` startet als einzige geoeffnete Informationskarte; Directory Coverage, Certificates & PKI, Infrastructure Details, Identity & Privilege, GPO & Password Hygiene, Control Paths & Trusts, Infrastructure & Resilience, Severity Distribution und Directory Overview starten minimiert und vollbreit. Alle Karten bleiben ueber die gemeinsamen Dreiecke individuell schaltbar. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests und Client-/Server-Produktionsbuild erfolgreich. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Detailkorrektur umgesetzt: minimierte Informationskarten behalten mit voller Spaltenbreite dieselbe Breite wie im maximierten Zustand; Collapse reduziert nur die Hoehe. Der PingCastle-Engine-Hinweis wurde aus `Directory Overview` entfernt, ohne die normalisierte Quellprovenienz zu veraendern. 13 fokussierte Dashboard-/Interaktionstests und Client-/Server-Produktionsbuild erfolgreich. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info auf stabile Spalten und eine geschlossene Kopfzone korrigiert. Das laufzeitgemessene dichte Masonry wurde durch drei feste, semantisch zugeordnete Kartenstapel mit 3/2/2 Karten ersetzt; minimierte und maximierte Karten bleiben dadurch immer in ihrer Spalte und nur nachfolgende Karten derselben Spalte ruecken auf. Die sechs KPI-Kacheln fuellen auf Desktop gemeinsam die Hoehe von `Assessment Status`, sind groesser typografiert und folgen nun Compliance Score, Deviation, High Severity, Passed, Typed Observations, Directory Sections. Compliance ist als H1 hervorgehoben; ausschliesslich Deviation und High Severity sind klickbar. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich. Im Gesamtlauf bestanden 780 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet noch die alte einzeilige Quelltextdarstellung der weiterhin korrekt vorhandenen `dashboard`-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Collapse geometrisch korrigiert und Plus/Minus durch Standarddreiecke ersetzt. Ein eigener `DashboardMasonryGrid` misst die zehn Karten, aktualisiert ihre Rasterspannen per ResizeObserver und packt sie dicht; dadurch rueckt die naechste Karte nach dem Schliessen sofort direkt unter die verkleinerte Kopfzeile. Vor erfolgreicher Messung beziehungsweise ohne ResizeObserver bleibt das normale sichere Grid aktiv. Beide gemeinsamen Kartentypen zeigen nun das App-weite SVG-Dreieck nach unten fuer offen und nach rechts fuer geschlossen. 16 fokussierte synthetische Dashboard-/Interaktions-/Masonry-/Icon-Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich. Im Gesamtlauf bestanden 781 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet noch eine alte einzeilige Quelltextdarstellung der weiterhin korrekt vorhandenen `dashboard`-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase 13B um die konkrete GPO-Sicht in Work und Result DB erweitert. Aktuelle Policy-Beobachtungen werden ohne Datenkopie aus Artefakt-, Processing-, Backup- und Identity-Persistenz als `policy-observation` projiziert, ueber `assessmentIds` demselben fachlichen Timeslot wie die Scannerlaeufe zugeordnet und mit Werten, technischen Identitaeten, Scope, Delivery Mechanism, Diagnosen und Quell-Locator exportierbar gemacht. Work besitzt ein eigenes Blatt `Group Policy · Observations` und fuehrt die Zeilen zusaetzlich in `All Records` mit `Source: Scan = Group Policy`; die Result DB zeigt sie unter dem Standardfilter als Kontext mit Strichen fuer IOE/Deviation. Die laufende Dummy-API wurde mit 51 GPO-Beobachtungen und zusammen 503 Resultaten im ausgewaehlten historischen Scope verifiziert. 13 fokussierte Tests, 781 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine SQL-Migration, keine Mutation der Dummy- oder Originaldaten; bestaetigte GPO-Findings bleiben weiterhin von Environment Profile, Identity-/Baseline-/Regelkatalog und Finding Factory abhaengig. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Faktenblock auf Nutzerwunsch als eigene Kachel umgesetzt. `Infrastructure & Resilience` zeigt nur noch seine Risiko-/Mengenwerte; die neue minimierbare Kachel `Infrastructure Details` enthaelt `Marked findings` und alle sieben bestehenden Environment-Fakten in der vollstaendigen responsiven Faktenliste. Der gemeinsame Kartenvertrag wurde fuer eine titel-only Kopfzeile optional erweitert; die Ansicht besitzt nun zehn Informationskacheln. 13 fokussierte Dashboard-/Interaktionstests, Local-only- und Designpruefung erfolgreich. Der vollstaendige TypeScript-Build bleibt durch eine unabhaengige parallel hinzugekommene GPO-Testfixture ohne `ImportProcessorReference.kind` blockiert; der nachgelagerte direkte Vite-Build konnte wegen Sandbox-`spawn EPERM` nicht separat ausgefuehrt werden. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Maester-Fehlermeldungen lesbar strukturiert. Ein neuer reiner Parser erkennt ausschliesslich eindeutige `Invoke-MgGraphRequest`-Ausfuehrungsfehler, extrahiert Graph-Meldung, Fehlercode, HTTP-Status, Methode/Endpunkt, Skriptzeile, Zeit und Korrelations-IDs und bewahrt den unveraenderten Rohtext. Das Results-Fenster zeigt die Ursache prominent, technische Werte in einer responsiven Feldmatrix und den Rohbeleg standardmaessig eingeklappt; normale Resultate bleiben unveraendert. 48 fokussierte synthetische Result-/Reader-Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich. Im Gesamtlauf bestanden 777 Tests; eine unabhaengige statische Result-DB-Scope-Regression ist aufgrund einer parallel vorhandenen `assessmentIds`-Quelltextergaenzung rot, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Minimierung korrigiert und alle neun Informationskacheln geprueft. Die sechs Mengen-/Faktenkarten und die drei Karten der rechten beziehungsweise oberen Donut-/Faktenzone starten vollstaendig sichtbar mit Minus; ein Klick reduziert jede Karte auf die reine Kopfzeile mit Plus und intrinsischer Kopfzeilenhoehe, ein weiterer stellt den vollstaendigen Inhalt wieder her. Damit bleibt eine minimierte Karte auch neben einer hoeheren geoeffneten Rasterkarte nicht als leere Vollhoehenflaeche stehen. Beide gemeinsamen Kartenkomponenten verwenden denselben zugaenglichen Inhaltscontainer, die sechs reinen Zahlenkacheln bleiben unveraendert. 13 fokussierte Dashboard-/Interaktionstests, 776 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Ausschliesslich synthetische Testdaten verwendet; keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung. Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase 13B um die erreichbare GPO-Analyse im Data-Hub-Clientdetail erweitert. Erfolgreiche GPO-Zeilen zeigen statt einer ausgegrauten Scanner-Checkbox eine eigenstaendige GPO-Markierung und `View`; die Aktion oeffnet die vorhandene projektgebundene Workflow-, Summary-, Historien- und Review-Oberflaeche und bietet den Rueckweg zu den Clientinformationen. Der Scanner-Provenienzfilter bleibt unveraendert scannerbezogen. Am freigegebenen Dummy-Client wurde die Anzeige mit einem vollstaendig verarbeiteten Drei-GPO-Paket, 51 Beobachtungen und 48 aktuellen Review-Eintraegen inhaltsbezogen verifiziert. Sieben fokussierte Tests, 775 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit. |
|
||||||
|
| 2026-09-03 | Phase 13B um die fehlende Data-Hub-Sichtbarkeit und den direkten ZIP-Auswahlweg fuer GPO-Backups ergaenzt. GPO-Artefakte erscheinen nun als eigene Quellenzeilen im zugeordneten Timeslot, fliessen in dessen Zaehler ein, zeigen ihren technischen Verarbeitungszustand und bieten bei Dead Letter Wiederholung beziehungsweise Loeschung. Ein `assessor:gpo-changed`-Refresh macht neue Einreichungen unmittelbar sichtbar. Die getrennten Buttons `Import GPO Folder` und `Import GPO ZIP` fuehren normalen Ordner beziehungsweise vorhandenes Archiv derselben bestehenden Backend-Ingestion zu; der Ordner wird zuvor weiterhin lokal deterministisch verpackt. 14 fokussierte Tests, 774 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL-, Persistenz- oder Realdatenaenderung; Phase 13B bleibt in Arbeit. |
|
||||||
|
| 2026-09-03 | Phase 13B um die Betriebsgrenze fuer kuratierte Policy-Regelkataloge erweitert: optionale lokale JSON-Datei mit verpflichtendem SHA-256-Pin, 5-MiB-Limit, Regular-file-/Symlink-Schutz, fataler UTF-8-/JSON-Pruefung und bestehender geschlossener fachlicher Freigabevalidierung. Ungueltige oder unvollstaendige Konfiguration stoppt vor Repository- und Serverstart; ohne Datei bleibt der fachlich leere sichere Katalog aktiv. Konfiguration in `docs/LOCAL_BACKEND.md` dokumentiert. Drei neue synthetische Loaderregressionen, 28 fokussierte Tests und Gesamtlauf mit 773 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests erfolgreich; Local-only-, Designpruefung und Client-/Serverbuild gruen. Keine Netzwerk-, API-, SQL-, Persistenz- oder Realdatenaenderung; Phase 13B bleibt bis zur fachlichen Regel- und Inbox-ADMX-/ADML-Kuratierung in Arbeit. |
|
||||||
|
| 2026-09-03 | Phase 13B um snapshotbezogene Ergebnisdeduplizierung erweitert: Das additive `current`-Readmodell verfolgt die aktive Profil-/Identity-/Aggregation-/Evaluation-/Finding-Kette, waehrend `summary`, Evaluationen und Reviewdaten der vollstaendigen Audit-Historie erhalten bleiben. Die sichtbare Summary und Current Review Queue nutzen nur den aktiven Zustand; die Evaluation History bleibt separat sichtbar. Evaluate- und Finding-Aktionen fuer veraltete Ketten werden nicht mehr angeboten. Drei Domainregressionen sowie Panel-, Actions- und HTTP-Regression erfolgreich; Gesamtlauf 770 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzaenderung, keine Realdaten gelesen; Phase 13B bleibt in Arbeit. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Infopanels auf die abschliessend bestaetigte Bildreferenz korrigiert: Minimiert enthalten alle neun Panels nur die luftige Kopfzeile mit `Titel — Primaermenge` und Plus, ohne Balken. Expandiert zeigen Mengenbereiche 120-px-Datenzeilen mit 72-px-dicken Mengenbalken; Assessment Status und Severity Distribution wurden von Donuts auf dieselbe Balkenkomponente umgestellt, wobei ihre Filteraktionen erhalten bleiben. Faktenzeilen wachsen mit 72-px-Mindesthoehe, die kompakte Resilience-Ausnahme wurde entfernt und schmale Ansichten stapeln Wert und 44-px-Balken responsiv. Die sechs Zahlenkacheln blieben unveraendert. 12 fokussierte Tests, 774 bestandene Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info abschliessend an die neue Gesamtansicht angepasst: sechs unveraenderte Zahlenkacheln oben, darunter sechs dauerhaft sichtbare Mengenpanels als dreispaltige Matrix und rechts eine eigene Rail mit Assessment-/Severity-Donuts sowie Directory Overview. Plus/Minus blendet nur Zusatzinformationen ein; die Kernzeilen bleiben sichtbar. Die Mengenzeilen entsprechen mit 64-px-Zeilen und 30-px-Balken der kompakten Bildgeometrie, nutzen reale Werte und behalten bestehende Filteraktionen der Donutlegenden. Responsive Breakpoints reduzieren die Matrix auf zwei/eine Spalte und stapeln die Rail. 12 fokussierte Tests, 774 bestandene Standardtests, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Kopfzeile verdichtet: Die sechs quadratischen Zahlenkacheln belegen als verkleinerte Sechserspur die linken drei Viertel; die Assessment-Status-Donutkarte steht im rechten Viertel derselben ersten Reihe. Severity Distribution und Directory Overview ruecken dadurch in der rechten Rail nach oben. Die Kopfzone stapelt sich ab 1100 px; die Kennzahlen wechseln dort auf drei und ab 800 px auf zwei Spalten. 12 fokussierte Layout-/Interaktionstests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Standardziel von `Open Client` in Phase 7 von Work auf Client Info umgestellt. Der zentrale Data-Hub-Auswahlcallback aktiviert `dashboard` und oeffnet weiterhin exakt das gewaehlte Projekt mit denselben ScanRun-IDs; dadurch gilt die Aenderung sowohl fuer Quick Open als auch fuer Open aus der Clientdetailseite. Neun fokussierte Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Sichtbare Phase-7-Breitenabweichung der expandierten Client-Info-Karten korrigiert: Statt auf der minimierten Zwei-Spalten-Breite zu bleiben, belegen geoeffnete Panels nun sechs Rasterspalten; bei 800 beziehungsweise 560 px wechseln sie auf die gesamte verfuegbare Vier- beziehungsweise Zwei-Spalten-Breite. Dadurch liegen Label/Wert und der 72-px-Mengenbalken wie in der Referenz nebeneinander innerhalb der Karte, ohne Nachbarflaechen zu ueberdecken. Bereits im Kopf dargestellte Primaerwerte sind aus den expandierten Zeilen entfernt; neutrale Serien folgen der roten, orangen, gelben und violetten Referenzfolge, waehrend bestehende Fachfarbgruppen unveraendert bleiben. 12 fokussierte Layout-/Interaktionstests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info nach freigegebenem Telemetrieentwurf auf Progressive Disclosure umgestellt: Die sechs Zahlenkacheln sind unveraendert. Assessment Status, Severity Distribution, Directory Overview, Directory Coverage, Identity & Privilege, Control Paths & Trusts, Certificates & PKI, GPO & Password Hygiene sowie Infrastructure & Resilience starten kompakt mit ihrer realen Primaermenge und einem Mengenbalken. Plus/Minus blendet pro Karte die vollstaendigen bestehenden Donut-, Mengenbalken- und Fakteninformationen ein beziehungsweise aus; es wurden keine im Mockup erfundenen Bereiche oder Verlaufsdaten implementiert. 15 fokussierte Dashboard-/Interaktions-/Inset-Tests, 774 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Unmittelbare Phase-7-Nutzerkorrektur fuer echte responsive Kartenhoehen: Die zuvor gesetzten drei festen Rasterzeilen wurden wieder entfernt. Breakpointabhaengige Rastereinheiten bilden nun nur noch die Mindesthoehe; `minmax(..., auto)` und `min-content` lassen Inhaltskarten anhand ihrer vollstaendigen Werte wachsen. `Infrastructure & Resilience` verwendet automatische Inhaltshoehe, waehrend andere Karten quadratisch bleiben, solange ihr Inhalt hineinpasst. 14 fokussierte Dashboard-/Inset-Tests, 770 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Infrastructure-&-Resilience-Kachel gegen Inhaltsueberlauf korrigiert: Die Karte reserviert nun drei Rasterzeilen und hebt nur fuer diese inhaltsreiche Ausnahme das starre Quadratverhaeltnis auf. Damit bleiben auch lange vollstaendige OS-Aufzaehlungen innerhalb der Flaeche, waehrend Faktenlayout und alle uebrigen quadratischen Karten unveraendert bleiben. 14 fokussierte Dashboard-/Inset-Tests, 769 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Work-Finding-Kacheln auf das bestaetigte Referenzformat vereinheitlicht: 358 px feste Breite, 223 px Mindesthoehe, keine Restbreitenstreckung und weiterhin wachsender Inhalt. Kopfaufteilung mit Hexagon links sowie Bookmark/Results rechts bleibt erhalten; die Metadaten stehen stabil als Status, Kategorie, Severity plus Weight und Score. Die Regel ist auf direkte Work-Rasterkacheln begrenzt, sodass Pathfinder-Micro-Cards kompakt bleiben. 16 fokussierte Kachel-/Typografie-/Pathfinder-Tests, 768 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Result-DB auf `Deviation = Yes` als initialen und Reset-Filter gestellt und gefilterten JSONL-Export ergaenzt. Der Button laedt alle Treffer des aktuellen Projekt-/Timeslot- und Nutzerfilterzustands in stabilen 1.000er-Seiten, bricht bei veraenderter Gesamtmenge fail-closed ab und erzeugt ein versioniertes Manifest plus eine vollstaendige JSON-Zeile je Unified Result. Drei fokussierte Export-/Scope-Tests, 767 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL-, Persistenz- oder Realdatenaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-09-03 | Phase-7-Client-Info-Balkenlisten bereinigt: Label, Balken und Wert verwenden nun ein identisches Dreispaltenraster; eine kompakte zweite Wertzeile ist auch ohne Hinweis reserviert, sodass `Restricted` keine einzelnen Zeilen mehr verschiebt. Zahl und optionale Einheit sind getrennt formatiert und bleiben rechtsbuendig zusammen. Die Resilience-Kompaktkarte setzt die Standardplatzierung explizit auf ihr Einspaltenlayout zurueck. 13 fokussierte Dashboard-/Kacheltests, 767 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-26 | Phase-13B-Regelkatalog-Freigabegrenze gehaertet: Runtimevalidierung fuer geschlossene Auslieferungsmechanismen, Baseline-Dispositionen, Comparatorvarianten/-modi und Severitywerte ergaenzt; ueberlappende Selektoren, mehrdeutige kontextspezifische Regeln sowie widerspruechliche Risiko- und Abhaengigkeitsdefinitionen werden vor Serviceaktivierung fail-closed abgelehnt. Der fachliche Produktivkatalog bleibt bewusst leer. Der lokale private Artefaktspeicher zeigt wieder auf den aktuellen LocalAppData-Pfad ausserhalb des Webroots; Backend `ok`, App HTTP 200 und vorhandener Speicherroot wurden ohne Inhaltszugriff bestaetigt. Neun neue synthetische Katalogtests, 25 fokussierte GPO-/Katalogtests, 764 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit. |
|
| 2026-08-26 | Phase-13B-Regelkatalog-Freigabegrenze gehaertet: Runtimevalidierung fuer geschlossene Auslieferungsmechanismen, Baseline-Dispositionen, Comparatorvarianten/-modi und Severitywerte ergaenzt; ueberlappende Selektoren, mehrdeutige kontextspezifische Regeln sowie widerspruechliche Risiko- und Abhaengigkeitsdefinitionen werden vor Serviceaktivierung fail-closed abgelehnt. Der fachliche Produktivkatalog bleibt bewusst leer. Der lokale private Artefaktspeicher zeigt wieder auf den aktuellen LocalAppData-Pfad ausserhalb des Webroots; Backend `ok`, App HTTP 200 und vorhandener Speicherroot wurden ohne Inhaltszugriff bestaetigt. Neun neue synthetische Katalogtests, 25 fokussierte GPO-/Katalogtests, 764 bestandene Standardtests, 8 erwartungsgemaess uebersprungene optionale PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit. |
|
||||||
| 2026-08-25 | Phase-7-Markierungsfarbe appweit auf Gelb vereinheitlicht: Der neue Theme-Token `--marking-color` traegt in Dark und Light denselben Wert und steuert Kachelauswahl, allgemeine Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl. Die violette Datahub-Auswahlkontur entfaellt. Tastaturfokus bleibt blau, Scanner-/Workflow-/Severitystatus behalten ihre fachlichen Farben. Projektregel und Kachelregression wurden aktualisiert. 24 fokussierte Auswahl-/Dashboard-/Graphtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-25 | Phase-7-Markierungsfarbe appweit auf Gelb vereinheitlicht: Der neue Theme-Token `--marking-color` traegt in Dark und Light denselben Wert und steuert Kachelauswahl, allgemeine Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl. Die violette Datahub-Auswahlkontur entfaellt. Tastaturfokus bleibt blau, Scanner-/Workflow-/Severitystatus behalten ihre fachlichen Farben. Projektregel und Kachelregression wurden aktualisiert. 24 fokussierte Auswahl-/Dashboard-/Graphtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-25 | Phase-7-Kachelstandard appweit auf die freigegebene flache Client-Info-Geometrie umgestellt: `--tile-border` ist in beiden Themes transparent, `--tile-radius` verwendet den zentralen Nullradius und `--tile-card-shadow` ist deaktiviert. Datahub-, Dashboard-, Sidebar-, Work- und Pathfinder-Hover veraendern die Flaeche ohne dekorative Kontur; Fokus, Auswahl und Statusmarkierungen bleiben sichtbar. `AGENTS.md` und die gemeinsamen Kachelregressionen wurden auf den neuen verbindlichen Standard aktualisiert. 24 fokussierte Kachel-/Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-25 | Phase-7-Kachelstandard appweit auf die freigegebene flache Client-Info-Geometrie umgestellt: `--tile-border` ist in beiden Themes transparent, `--tile-radius` verwendet den zentralen Nullradius und `--tile-card-shadow` ist deaktiviert. Datahub-, Dashboard-, Sidebar-, Work- und Pathfinder-Hover veraendern die Flaeche ohne dekorative Kontur; Fokus, Auswahl und Statusmarkierungen bleiben sichtbar. `AGENTS.md` und die gemeinsamen Kachelregressionen wurden auf den neuen verbindlichen Standard aktualisiert. 24 fokussierte Kachel-/Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,26 @@ Start ab; es findet kein stiller Fallback statt.
|
||||||
Ein vollständiges lokales PostgreSQL-Setup, Schema-, Migrations-, Backup- und
|
Ein vollständiges lokales PostgreSQL-Setup, Schema-, Migrations-, Backup- und
|
||||||
Restore-Regeln stehen in `docs/POSTGRESQL.md`.
|
Restore-Regeln stehen in `docs/POSTGRESQL.md`.
|
||||||
|
|
||||||
|
## Kuratierter GPO-Regelkatalog
|
||||||
|
|
||||||
|
Ohne weitere Konfiguration startet die Policy-Evaluation mit dem freigegebenen,
|
||||||
|
fachlich leeren Katalog. Ein lokal kuratierter JSON-Katalog wird nur geladen,
|
||||||
|
wenn Pfad und erwarteter SHA-256 gemeinsam gesetzt sind:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ASSESSOR_POLICY_RULE_CATALOG_PATH=C:\geschuetzter-pfad\policy-rules.json
|
||||||
|
ASSESSOR_POLICY_RULE_CATALOG_SHA256=<64-stelliger SHA-256-Hexwert>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server lädt höchstens 5 MiB aus einer regulären Datei, vergleicht den
|
||||||
|
Inhalt vor dem JSON-Parsing mit dem fest konfigurierten Hash und validiert den
|
||||||
|
vollständigen Katalogvertrag einschließlich Kontextüberschneidungen,
|
||||||
|
Comparatoren, Risiko- und Abhängigkeitsregeln. Fehlende Paarparameter,
|
||||||
|
Hashabweichungen, Symlinks, ungültiges UTF-8/JSON oder fachlich ungültige Regeln
|
||||||
|
verhindern den Serverstart. Die Datei wird weder aus dem Netzwerk bezogen noch
|
||||||
|
durch die Anwendung verändert. Jeder Evaluation Run speichert weiterhin den
|
||||||
|
tatsächlich verwendeten vollständigen Katalog für reproduzierbares Reprocessing.
|
||||||
|
|
||||||
Originaldateien werden aktuell nicht als separate Serverartefakte abgelegt. Die
|
Originaldateien werden aktuell nicht als separate Serverartefakte abgelegt. Die
|
||||||
API verarbeitet die Bytes und speichert die daraus erzeugten kanonischen
|
API verarbeitet die Bytes und speichert die daraus erzeugten kanonischen
|
||||||
Fachobjekte. Eine spätere Aufbewahrung von Originalartefakten bleibt eine eigene
|
Fachobjekte. Eine spätere Aufbewahrung von Originalartefakten bleibt eine eigene
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ Ein Parserworker begrenzt Zeit und Speicher, isoliert aber keinen bösartigen na
|
||||||
| Skript- oder Binärausführung | Nur inventarisieren und hashen; niemals importieren, laden, entschlüsseln oder starten. |
|
| Skript- oder Binärausführung | Nur inventarisieren und hashen; niemals importieren, laden, entschlüsseln oder starten. |
|
||||||
| Manipulierte oder herstellerspezifisch erweiterte `Registry.pol`-Datensaetze erzeugen falsche Instruktionen oder Ressourcenverbrauch. | PReg-Signatur und Version, Feldabschluesse, Schluessel und Wertnamen, Typen, Datengroessen, Datensatzanzahl und Gesamtgroesse vor Reduktion begrenzen; nur freigegebene Typen und REG_NONE als exakt leere Schluesselanlage akzeptieren, sonst das Artefakt ohne Salvage blockieren. Keine Datei ausfuehren und keinen externen Prozess starten. |
|
| Manipulierte oder herstellerspezifisch erweiterte `Registry.pol`-Datensaetze erzeugen falsche Instruktionen oder Ressourcenverbrauch. | PReg-Signatur und Version, Feldabschluesse, Schluessel und Wertnamen, Typen, Datengroessen, Datensatzanzahl und Gesamtgroesse vor Reduktion begrenzen; nur freigegebene Typen und REG_NONE als exakt leere Schluesselanlage akzeptieren, sonst das Artefakt ohne Salvage blockieren. Keine Datei ausfuehren und keinen externen Prozess starten. |
|
||||||
| GPP-`cpassword` oder andere Geheimnisse | Nur Vorhandensein und Locator erfassen; Wert bereits an der Parsergrenze maskieren, niemals entschlüsseln, loggen oder exportieren. |
|
| GPP-`cpassword` oder andere Geheimnisse | Nur Vorhandensein und Locator erfassen; Wert bereits an der Parsergrenze maskieren, niemals entschlüsseln, loggen oder exportieren. |
|
||||||
|
| Manipulierter oder unbeabsichtigt ausgetauschter Policy-Regelkatalog | Einen extern kuratierten Katalog nur aus einer lokalen regulären Nicht-Symlink-Datei bis 5 MiB laden, wenn Pfad und erwarteter SHA-256 gemeinsam konfiguriert sind. Hash vor UTF-8-/JSON-Parsing prüfen, danach den vollständigen fachlichen Katalogvertrag fail-closed validieren und bei jeder Abweichung vor Repository- und Serverstart abbrechen. Keine automatische Netzwerkquelle oder stille Aktivierung. |
|
||||||
| Pfad-/Namensheuristik erzeugt falsche Policy- oder Zielidentität | Technische IDs ausschließlich aus unterstützten Artefaktinhalten und versionierten Regeln; Namen und Pfade bleiben Provenienz. |
|
| Pfad-/Namensheuristik erzeugt falsche Policy- oder Zielidentität | Technische IDs ausschließlich aus unterstützten Artefaktinhalten und versionierten Regeln; Namen und Pfade bleiben Provenienz. |
|
||||||
| Historische Backups werden gemeinsam aggregiert | Explizite Backup-GUID, GPO-GUID, Domänenidentität, manifestgestützte Set-ID und Erhebungsfenster prüfen; doppelte GPOs, Zeitkonflikte und Domänenmischung blockieren. |
|
| Historische Backups werden gemeinsam aggregiert | Explizite Backup-GUID, GPO-GUID, Domänenidentität, manifestgestützte Set-ID und Erhebungsfenster prüfen; doppelte GPOs, Zeitkonflikte und Domänenmischung blockieren. |
|
||||||
| Ausgewählte Teilmenge wird als vollständige Umgebung bewertet | Setvollständigkeit getrennt von Instanzvollständigkeit modellieren; negative Schlussfolgerungen nur bei manifestgestütztem vollständigem Set mit erwarteter und beobachteter Anzahl. |
|
| Ausgewählte Teilmenge wird als vollständige Umgebung bewertet | Setvollständigkeit getrennt von Instanzvollständigkeit modellieren; negative Schlussfolgerungen nur bei manifestgestütztem vollständigem Set mit erwarteter und beobachteter Anzahl. |
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ function unifiedQueryFrom(url: URL): UnifiedResultQuery {
|
||||||
return {
|
return {
|
||||||
projectId: url.searchParams.get('projectId') ?? undefined,
|
projectId: url.searchParams.get('projectId') ?? undefined,
|
||||||
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
|
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
|
||||||
|
assessmentIds: many('assessmentIds'),
|
||||||
scanRunId: url.searchParams.get('scanRunId') ?? undefined,
|
scanRunId: url.searchParams.get('scanRunId') ?? undefined,
|
||||||
scanRunIds: many('scanRunIds'),
|
scanRunIds: many('scanRunIds'),
|
||||||
evaluationRunId: url.searchParams.get('evaluationRunId') ?? undefined,
|
evaluationRunId: url.searchParams.get('evaluationRunId') ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { lstat, readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
RELEASED_POLICY_EVALUATION_RULE_CATALOG,
|
||||||
|
policyEvaluationRuleCatalogValidationErrors,
|
||||||
|
type PolicyEvaluationRuleCatalog
|
||||||
|
} from '../../src/domain/policy-evaluation-rules';
|
||||||
|
|
||||||
|
export const MAXIMUM_POLICY_RULE_CATALOG_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface PolicyEvaluationRuleCatalogLoadOptions {
|
||||||
|
path?: string;
|
||||||
|
expectedSha256?: string;
|
||||||
|
maximumBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidationShape(value: unknown): value is PolicyEvaluationRuleCatalog {
|
||||||
|
if (!isObject(value) || !isObject(value.catalog) || !Array.isArray(value.rules)) return false;
|
||||||
|
return value.rules.every(rule => isObject(rule)
|
||||||
|
&& Array.isArray(rule.contexts)
|
||||||
|
&& rule.contexts.every(context => isObject(context)
|
||||||
|
&& Array.isArray(context.roles) && Array.isArray(context.scopes))
|
||||||
|
&& Array.isArray(rule.dependencies)
|
||||||
|
&& rule.dependencies.every(isObject)
|
||||||
|
&& (rule.comparator === undefined || isObject(rule.comparator))
|
||||||
|
&& (rule.riskRule === undefined || isObject(rule.riskRule)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSha256(value: string | undefined): string | undefined {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(normalized)) {
|
||||||
|
throw new Error('ASSESSOR_POLICY_RULE_CATALOG_SHA256 muss ein SHA-256-Hexwert sein.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPolicyEvaluationRuleCatalog(
|
||||||
|
options: PolicyEvaluationRuleCatalogLoadOptions = {}
|
||||||
|
): Promise<PolicyEvaluationRuleCatalog> {
|
||||||
|
const configuredPath = options.path?.trim();
|
||||||
|
const expectedSha256 = normalizedSha256(options.expectedSha256);
|
||||||
|
if (!configuredPath && !expectedSha256) return structuredClone(RELEASED_POLICY_EVALUATION_RULE_CATALOG);
|
||||||
|
if (!configuredPath || !expectedSha256) {
|
||||||
|
throw new Error('Policy-Regelkatalogpfad und SHA-256 muessen gemeinsam konfiguriert werden.');
|
||||||
|
}
|
||||||
|
const maximumBytes = options.maximumBytes ?? MAXIMUM_POLICY_RULE_CATALOG_BYTES;
|
||||||
|
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > MAXIMUM_POLICY_RULE_CATALOG_BYTES) {
|
||||||
|
throw new Error('Die Groessengrenze des Policy-Regelkatalogs ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = resolve(configuredPath);
|
||||||
|
const information = await lstat(absolutePath);
|
||||||
|
if (information.isSymbolicLink() || !information.isFile()) {
|
||||||
|
throw new Error('Der Policy-Regelkatalog muss eine regulaere Datei sein.');
|
||||||
|
}
|
||||||
|
if (information.size < 1 || information.size > maximumBytes) {
|
||||||
|
throw new Error('Der Policy-Regelkatalog verletzt die konfigurierte Groessengrenze.');
|
||||||
|
}
|
||||||
|
const bytes = await readFile(absolutePath);
|
||||||
|
if (bytes.byteLength !== information.size || bytes.byteLength > maximumBytes) {
|
||||||
|
throw new Error('Der Policy-Regelkatalog wurde waehrend des Lesens veraendert oder ist zu gross.');
|
||||||
|
}
|
||||||
|
const actualSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||||
|
if (!timingSafeEqual(Buffer.from(actualSha256, 'hex'), Buffer.from(expectedSha256, 'hex'))) {
|
||||||
|
throw new Error('Der Policy-Regelkatalog stimmt nicht mit dem konfigurierten SHA-256 ueberein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\uFEFF/, '');
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Der Policy-Regelkatalog ist kein gueltiges UTF-8-JSON-Dokument.');
|
||||||
|
}
|
||||||
|
if (!hasValidationShape(parsed)) {
|
||||||
|
throw new Error('Der Policy-Regelkatalog besitzt keine gueltige Struktur.');
|
||||||
|
}
|
||||||
|
const errors = policyEvaluationRuleCatalogValidationErrors(parsed);
|
||||||
|
if (errors.length) throw new Error(`Der Policy-Regelkatalog wurde abgelehnt: ${errors.join(' ')}`);
|
||||||
|
return structuredClone(parsed);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
|
||||||
import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
|
import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
|
||||||
import { NvdCveClient } from './cve/nvd-cve-client';
|
import { NvdCveClient } from './cve/nvd-cve-client';
|
||||||
import { CveEnrichmentService } from './cve/cve-enrichment-service';
|
import { CveEnrichmentService } from './cve/cve-enrichment-service';
|
||||||
|
import { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-catalog-loader';
|
||||||
|
|
||||||
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
|
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
|
||||||
if (value === undefined || value.trim() === '') return fallback;
|
if (value === undefined || value.trim() === '') return fallback;
|
||||||
|
|
@ -52,6 +53,10 @@ async function main(): Promise<void> {
|
||||||
maximumOldGenerationMb: boundedInteger(process.env.ASSESSOR_GPO_MEMORY_MB, 1_024, 256, 2_048, 'ASSESSOR_GPO_MEMORY_MB'),
|
maximumOldGenerationMb: boundedInteger(process.env.ASSESSOR_GPO_MEMORY_MB, 1_024, 256, 2_048, 'ASSESSOR_GPO_MEMORY_MB'),
|
||||||
maximumYoungGenerationMb: boundedInteger(process.env.ASSESSOR_GPO_YOUNG_MEMORY_MB, 128, 64, 512, 'ASSESSOR_GPO_YOUNG_MEMORY_MB')
|
maximumYoungGenerationMb: boundedInteger(process.env.ASSESSOR_GPO_YOUNG_MEMORY_MB, 128, 64, 512, 'ASSESSOR_GPO_YOUNG_MEMORY_MB')
|
||||||
});
|
});
|
||||||
|
const policyEvaluationRuleCatalog = await loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: process.env.ASSESSOR_POLICY_RULE_CATALOG_PATH,
|
||||||
|
expectedSha256: process.env.ASSESSOR_POLICY_RULE_CATALOG_SHA256
|
||||||
|
});
|
||||||
const runtime = await createRepositoryRuntime();
|
const runtime = await createRepositoryRuntime();
|
||||||
const artifactStore = new FileSystemBackendArtifactStore(artifactRoot);
|
const artifactStore = new FileSystemBackendArtifactStore(artifactRoot);
|
||||||
const gpoBackupSubmissionService = new GpoBackupSubmissionService(
|
const gpoBackupSubmissionService = new GpoBackupSubmissionService(
|
||||||
|
|
@ -65,7 +70,9 @@ async function main(): Promise<void> {
|
||||||
runtime.repository, artifactStore, defaultImportResourcePolicy.maximumFileBytes, gpoInventoryExecutor
|
runtime.repository, artifactStore, defaultImportResourcePolicy.maximumFileBytes, gpoInventoryExecutor
|
||||||
);
|
);
|
||||||
const gpoPolicySnapshotService = new GpoPolicySnapshotService(runtime.repository);
|
const gpoPolicySnapshotService = new GpoPolicySnapshotService(runtime.repository);
|
||||||
const gpoPolicyEvaluationService = new GpoPolicyEvaluationService(runtime.repository);
|
const gpoPolicyEvaluationService = new GpoPolicyEvaluationService(runtime.repository, {
|
||||||
|
ruleCatalog: policyEvaluationRuleCatalog
|
||||||
|
});
|
||||||
const gpoPolicyFindingService = new GpoPolicyFindingService(runtime.repository);
|
const gpoPolicyFindingService = new GpoPolicyFindingService(runtime.repository);
|
||||||
const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository);
|
const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository);
|
||||||
const cveEnrichmentService = new CveEnrichmentService(runtime.repository, new NvdCveClient({
|
const cveEnrichmentService = new CveEnrichmentService(runtime.repository, new NvdCveClient({
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import type { DbStructureColumn, DbStructureForeignKey, DbStructureLayerId, DbSt
|
||||||
import { storedGpoBackupInstanceValidationErrors, storedGpoPolicyAnalysisValidationErrors } from '../../src/domain/gpo-analysis-persistence';
|
import { storedGpoBackupInstanceValidationErrors, storedGpoPolicyAnalysisValidationErrors } from '../../src/domain/gpo-analysis-persistence';
|
||||||
import {
|
import {
|
||||||
createEnvironmentUnifiedResult,
|
createEnvironmentUnifiedResult,
|
||||||
|
createCurrentPolicyObservationUnifiedResults,
|
||||||
createPolicyUnifiedResult,
|
createPolicyUnifiedResult,
|
||||||
createScannerUnifiedResult,
|
createScannerUnifiedResult,
|
||||||
deriveUnifiedResultFacets,
|
deriveUnifiedResultFacets,
|
||||||
|
|
@ -1077,13 +1078,36 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async unifiedPolicyObservationResults(
|
||||||
|
query: UnifiedResultQuery = {},
|
||||||
|
observationId?: string
|
||||||
|
): Promise<UnifiedResultProjection[]> {
|
||||||
|
if (query.sourceKind?.length && !query.sourceKind.includes('policy-observation')) return [];
|
||||||
|
const where = query.projectId ? 'WHERE project_id = $1' : '';
|
||||||
|
const values = query.projectId ? [query.projectId] : [];
|
||||||
|
const [artifactRows, processingRows, backupRows, analysisRows] = await Promise.all([
|
||||||
|
this.pool.query<DataRow<ImportArtifact>>(`SELECT data FROM assessor_import_artifacts ${where} ORDER BY created_at, id`, values),
|
||||||
|
this.pool.query<DataRow<ImportProcessingRun>>(`SELECT data FROM assessor_import_processing_runs ${where} ORDER BY completed_at, id`, values),
|
||||||
|
this.pool.query<DataRow<StoredGpoBackupInstance>>(`SELECT data FROM assessor_gpo_backup_instances ${where} ORDER BY created_at, id`, values),
|
||||||
|
this.pool.query<DataRow<StoredGpoPolicyAnalysis>>(`SELECT data FROM assessor_policy_identity_analysis_runs ${where} ORDER BY completed_at, id`, values)
|
||||||
|
]);
|
||||||
|
const results = createCurrentPolicyObservationUnifiedResults(
|
||||||
|
dataRows(artifactRows),
|
||||||
|
dataRows(processingRows),
|
||||||
|
dataRows(backupRows),
|
||||||
|
dataRows(analysisRows)
|
||||||
|
);
|
||||||
|
return observationId ? results.filter(item => item.sourceResultId === observationId) : results;
|
||||||
|
}
|
||||||
|
|
||||||
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
|
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
|
||||||
const [scanner, environment, policy] = await Promise.all([
|
const [scanner, environment, policyObservations, policy] = await Promise.all([
|
||||||
this.unifiedScannerResults(query),
|
this.unifiedScannerResults(query),
|
||||||
this.unifiedEnvironmentResults(query),
|
this.unifiedEnvironmentResults(query),
|
||||||
|
this.unifiedPolicyObservationResults(query),
|
||||||
this.unifiedPolicyResults(query)
|
this.unifiedPolicyResults(query)
|
||||||
]);
|
]);
|
||||||
return [...scanner, ...environment, ...policy];
|
return [...scanner, ...environment, ...policyObservations, ...policy];
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||||
|
|
@ -1097,11 +1121,14 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
|
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
|
||||||
const scannerPrefix = 'scanner-finding:';
|
const scannerPrefix = 'scanner-finding:';
|
||||||
const policyPrefix = 'policy-evaluation:';
|
const policyPrefix = 'policy-evaluation:';
|
||||||
|
const policyObservationPrefix = 'policy-observation:';
|
||||||
const environmentPrefix = 'environment-observation:';
|
const environmentPrefix = 'environment-observation:';
|
||||||
const items = resultProjectionId.startsWith(scannerPrefix)
|
const items = resultProjectionId.startsWith(scannerPrefix)
|
||||||
? await this.unifiedScannerResults({}, resultProjectionId.slice(scannerPrefix.length))
|
? await this.unifiedScannerResults({}, resultProjectionId.slice(scannerPrefix.length))
|
||||||
: resultProjectionId.startsWith(policyPrefix)
|
: resultProjectionId.startsWith(policyPrefix)
|
||||||
? await this.unifiedPolicyResults({}, resultProjectionId.slice(policyPrefix.length))
|
? await this.unifiedPolicyResults({}, resultProjectionId.slice(policyPrefix.length))
|
||||||
|
: resultProjectionId.startsWith(policyObservationPrefix)
|
||||||
|
? await this.unifiedPolicyObservationResults({}, resultProjectionId.slice(policyObservationPrefix.length))
|
||||||
: resultProjectionId.startsWith(environmentPrefix)
|
: resultProjectionId.startsWith(environmentPrefix)
|
||||||
? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length))
|
? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length))
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -151,10 +151,26 @@ export function App() {
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
{activeArea === 'graph' && dataset && !hasClientTimeslotScope && <div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to build its graph.</p></div>}
|
{activeArea === 'graph' && dataset && !hasClientTimeslotScope && <div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to build its graph.</p></div>}
|
||||||
{activeArea === 'data-hub' && <DataHub activeProjectId={targetProjectId} backendStatus={backendStatus} onDetailViewChanged={handleDataHubDetailViewChanged} onProjectDataChanged={async projectId => { await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined); }} onProjectSelected={(project, scanRunIds) => { setActiveProjectName(project?.name); if (project) { setActiveArea('work'); void app.openProject(project.id, scanRunIds); } else app.setTargetProjectId(undefined); }}/>}
|
{activeArea === 'data-hub' && (
|
||||||
|
<DataHub
|
||||||
|
activeProjectId={targetProjectId}
|
||||||
|
backendStatus={backendStatus}
|
||||||
|
onDetailViewChanged={handleDataHubDetailViewChanged}
|
||||||
|
onProjectDataChanged={async projectId => {
|
||||||
|
await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined);
|
||||||
|
}}
|
||||||
|
onProjectSelected={(project, scanRunIds) => {
|
||||||
|
setActiveProjectName(project?.name);
|
||||||
|
if (project) {
|
||||||
|
setActiveArea('dashboard');
|
||||||
|
void app.openProject(project.id, scanRunIds);
|
||||||
|
} else app.setTargetProjectId(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={() => setActiveArea('work')}/>}
|
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={() => setActiveArea('work')}/>}
|
||||||
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
||||||
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds}/>}
|
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds} assessmentIds={app.activeAssessmentIds}/>}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ interface AppContextValue extends AppState {
|
||||||
applyViewBookmark(view: SavedViewState): void;
|
applyViewBookmark(view: SavedViewState): void;
|
||||||
targetProjectId?: string;
|
targetProjectId?: string;
|
||||||
activeScanRunIds?: string[];
|
activeScanRunIds?: string[];
|
||||||
|
activeAssessmentIds?: string[];
|
||||||
workScopeId: string;
|
workScopeId: string;
|
||||||
setTargetProjectId: (projectId?: string) => void;
|
setTargetProjectId: (projectId?: string) => void;
|
||||||
openProject(projectId: string, scanRunIds?: string[]): Promise<boolean>;
|
openProject(projectId: string, scanRunIds?: string[]): Promise<boolean>;
|
||||||
|
|
@ -115,6 +116,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const [importWorkflow, setImportWorkflow] = useState<ImportWorkflowState>({ phase: 'idle' });
|
const [importWorkflow, setImportWorkflow] = useState<ImportWorkflowState>({ phase: 'idle' });
|
||||||
const [targetProjectId, setTargetProjectId] = useState<string | undefined>();
|
const [targetProjectId, setTargetProjectId] = useState<string | undefined>();
|
||||||
const [activeScanRunIds, setActiveScanRunIds] = useState<string[] | undefined>();
|
const [activeScanRunIds, setActiveScanRunIds] = useState<string[] | undefined>();
|
||||||
|
const [activeAssessmentIds, setActiveAssessmentIds] = useState<string[] | undefined>();
|
||||||
const [workScopeId, setWorkScopeId] = useState('');
|
const [workScopeId, setWorkScopeId] = useState('');
|
||||||
const openProjectRequest = useRef(0);
|
const openProjectRequest = useRef(0);
|
||||||
const [state, setState] = useState<AppState>(() => {
|
const [state, setState] = useState<AppState>(() => {
|
||||||
|
|
@ -398,13 +400,14 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
// Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens
|
// Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens
|
||||||
// weder den vorherigen Timeslot noch den gesamten neuen Client anzeigen.
|
// weder den vorherigen Timeslot noch den gesamten neuen Client anzeigen.
|
||||||
setActiveScanRunIds(scanRunIds?.length ? [...new Set(scanRunIds)] : undefined);
|
setActiveScanRunIds(scanRunIds?.length ? [...new Set(scanRunIds)] : undefined);
|
||||||
|
setActiveAssessmentIds(undefined);
|
||||||
setState(current => ({
|
setState(current => ({
|
||||||
...current,
|
...current,
|
||||||
projectId,
|
projectId,
|
||||||
status: 'Loading saved client …'
|
status: 'Loading saved client …'
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const { models, dataset } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds);
|
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds);
|
||||||
if (requestId !== openProjectRequest.current) return false;
|
if (requestId !== openProjectRequest.current) return false;
|
||||||
if (!models.length) {
|
if (!models.length) {
|
||||||
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
|
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
|
||||||
|
|
@ -437,6 +440,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
pathFindingIds: [],
|
pathFindingIds: [],
|
||||||
status: models[0].project.name + ' · ' + dataset.scanTool + ' · ' + models.length.toLocaleString('en-US') + ' scan report' + (models.length === 1 ? '' : 's') + ' · ' + activeSheet.analysis.records.length.toLocaleString('en-US') + ' records loaded'
|
status: models[0].project.name + ' · ' + dataset.scanTool + ' · ' + models.length.toLocaleString('en-US') + ' scan report' + (models.length === 1 ? '' : 's') + ' · ' + activeSheet.analysis.records.length.toLocaleString('en-US') + ' records loaded'
|
||||||
}));
|
}));
|
||||||
|
setActiveAssessmentIds(assessmentIds);
|
||||||
setWorkScopeId(scopeId);
|
setWorkScopeId(scopeId);
|
||||||
return true;
|
return true;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|
@ -513,10 +517,14 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
applyViewBookmark: savedView => setState(current => applySavedViewState(current, savedView)),
|
applyViewBookmark: savedView => setState(current => applySavedViewState(current, savedView)),
|
||||||
targetProjectId,
|
targetProjectId,
|
||||||
activeScanRunIds,
|
activeScanRunIds,
|
||||||
|
activeAssessmentIds,
|
||||||
workScopeId,
|
workScopeId,
|
||||||
setTargetProjectId: projectId => {
|
setTargetProjectId: projectId => {
|
||||||
setTargetProjectId(projectId);
|
setTargetProjectId(projectId);
|
||||||
if (!projectId) setActiveScanRunIds(undefined);
|
if (!projectId) {
|
||||||
|
setActiveScanRunIds(undefined);
|
||||||
|
setActiveAssessmentIds(undefined);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
openProject,
|
openProject,
|
||||||
setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })),
|
setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })),
|
||||||
|
|
@ -563,7 +571,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
if (!Object.values(current.reviews).some(review => review.include)) return current;
|
if (!Object.values(current.reviews).some(review => review.include)) return current;
|
||||||
return { ...current, actionOnly: !current.actionOnly, view: 'columns' };
|
return { ...current, actionOnly: !current.actionOnly, view: 'columns' };
|
||||||
})
|
})
|
||||||
}), [activeProfile, activeScanRunIds, activeSheet, closeImportWorkflow, commitImportBatch, confirmImportEntry, importFiles, importWorkflow, setImportEntrySelected, setTheme, state, updateReview, targetProjectId, workScopeId, openProject]);
|
}), [activeProfile, activeAssessmentIds, activeScanRunIds, activeSheet, closeImportWorkflow, commitImportBatch, confirmImportEntry, importFiles, importWorkflow, setImportEntrySelected, setTheme, state, updateReview, targetProjectId, workScopeId, openProject]);
|
||||||
|
|
||||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,14 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
|
||||||
const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value;
|
const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value;
|
||||||
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
|
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
|
||||||
});
|
});
|
||||||
badges.push(...cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value })));
|
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value }));
|
||||||
|
const orderedBadges = [
|
||||||
|
...badges.filter(badge => badge.label === 'Status' || badge.label === 'Category'),
|
||||||
|
...cisBadges,
|
||||||
|
...badges.filter(badge => badge.label !== 'Status' && badge.label !== 'Category')
|
||||||
|
];
|
||||||
|
|
||||||
return badges.filter((badge, index) => !badges.slice(0, index).some(previous => previous.value.trim().toLocaleLowerCase('en-US') === badge.value.trim().toLocaleLowerCase('en-US')));
|
return orderedBadges.filter((badge, index) => !orderedBadges.slice(0, index).some(previous => previous.value.trim().toLocaleLowerCase('en-US') === badge.value.trim().toLocaleLowerCase('en-US')));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactTileBadges(badges: TileBadge[], limit?: number): TileBadge[] {
|
export function compactTileBadges(badges: TileBadge[], limit?: number): TileBadge[] {
|
||||||
|
|
@ -94,8 +99,8 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
|
||||||
/* Der Ergebnisstatus ist ein Outcome, keine Meta-Plakette: Textfarbe
|
/* Der Ergebnisstatus ist ein Outcome, keine Meta-Plakette: Textfarbe
|
||||||
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
|
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
|
||||||
und Investigate in der Kachelansicht identisch aus. */
|
und Investigate in der Kachelansicht identisch aus. */
|
||||||
? <span className="outcome-mark" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
|
? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
|
||||||
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
|
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="data-tile-actions">
|
<div className="data-tile-actions">
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,31 @@ export interface PolicyAnalysisProfileSummary {
|
||||||
contextCount: number;
|
contextCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PolicyAnalysisSummary {
|
||||||
|
identityAnalysisRuns: number;
|
||||||
|
aggregationRuns: number;
|
||||||
|
evaluationRuns: number;
|
||||||
|
findingRuns: number;
|
||||||
|
configuredMatches: number;
|
||||||
|
confirmedDeviations: number;
|
||||||
|
notApplicable: number;
|
||||||
|
policyFindings: number;
|
||||||
|
reviewItems: number;
|
||||||
|
conflicts: number;
|
||||||
|
unresolvedIdentities: number;
|
||||||
|
catalogGaps: number;
|
||||||
|
parserGaps: number;
|
||||||
|
partialRuns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyAnalysisCurrentState {
|
||||||
|
version: 1;
|
||||||
|
summary: PolicyAnalysisSummary;
|
||||||
|
evaluationRunIds: string[];
|
||||||
|
findingRunIds: string[];
|
||||||
|
reviewItems: PolicyAnalysisReviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export type PolicyAnalysisActionStatus = 'ready' | 'review-only' | 'completed' | 'blocked';
|
export type PolicyAnalysisActionStatus = 'ready' | 'review-only' | 'completed' | 'blocked';
|
||||||
|
|
||||||
export interface PolicyAnalysisReprocessAction {
|
export interface PolicyAnalysisReprocessAction {
|
||||||
|
|
@ -118,22 +143,8 @@ export interface PolicyAnalysisOverview {
|
||||||
version: 1;
|
version: 1;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
summary: {
|
summary: PolicyAnalysisSummary;
|
||||||
identityAnalysisRuns: number;
|
current: PolicyAnalysisCurrentState;
|
||||||
aggregationRuns: number;
|
|
||||||
evaluationRuns: number;
|
|
||||||
findingRuns: number;
|
|
||||||
configuredMatches: number;
|
|
||||||
confirmedDeviations: number;
|
|
||||||
notApplicable: number;
|
|
||||||
policyFindings: number;
|
|
||||||
reviewItems: number;
|
|
||||||
conflicts: number;
|
|
||||||
unresolvedIdentities: number;
|
|
||||||
catalogGaps: number;
|
|
||||||
parserGaps: number;
|
|
||||||
partialRuns: number;
|
|
||||||
};
|
|
||||||
actions: PolicyAnalysisActions;
|
actions: PolicyAnalysisActions;
|
||||||
profiles: PolicyAnalysisProfileSummary[];
|
profiles: PolicyAnalysisProfileSummary[];
|
||||||
evaluations: PolicyAnalysisEvaluationSummary[];
|
evaluations: PolicyAnalysisEvaluationSummary[];
|
||||||
|
|
@ -194,16 +205,94 @@ function sameIds(left: string[], right: string[]): boolean {
|
||||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisActions {
|
function latestByKey<T>(
|
||||||
|
records: T[],
|
||||||
|
key: (record: T) => string,
|
||||||
|
completedAt: (record: T) => string,
|
||||||
|
id: (record: T) => string
|
||||||
|
): T[] {
|
||||||
|
const sorted = [...records].sort((left, right) => completedAt(right).localeCompare(completedAt(left))
|
||||||
|
|| id(left).localeCompare(id(right)));
|
||||||
|
const latest = new Map<string, T>();
|
||||||
|
for (const record of sorted) if (!latest.has(key(record))) latest.set(key(record), record);
|
||||||
|
return [...latest.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePolicyRecords(input: BuildPolicyAnalysisOverviewInput): {
|
||||||
|
identityAnalyses: StoredGpoPolicyAnalysis[];
|
||||||
|
aggregations: StoredPolicySnapshotAggregation[];
|
||||||
|
evaluations: StoredPolicyEvaluation[];
|
||||||
|
findingRuns: StoredPolicyFindingRun[];
|
||||||
|
} {
|
||||||
const supersededProfileIds = new Set(input.profiles.map(profile => profile.supersedesProfileId).filter(Boolean));
|
const supersededProfileIds = new Set(input.profiles.map(profile => profile.supersedesProfileId).filter(Boolean));
|
||||||
const activeProfiles = input.profiles.filter(profile => !supersededProfileIds.has(profile.id));
|
const activeProfileIds = new Set(input.profiles.filter(profile => !supersededProfileIds.has(profile.id)).map(profile => profile.id));
|
||||||
const supersededAnalysisIds = new Set(input.identityAnalyses
|
const supersededAnalysisIds = new Set(input.identityAnalyses
|
||||||
.map(record => record.run.supersedesAnalysisRunId).filter(Boolean));
|
.map(record => record.run.supersedesAnalysisRunId).filter(Boolean));
|
||||||
const activeIdentityAnalyses = input.identityAnalyses
|
const identityAnalyses = latestByKey(
|
||||||
.filter(record => !supersededAnalysisIds.has(record.run.id))
|
input.identityAnalyses.filter(record => !supersededAnalysisIds.has(record.run.id)),
|
||||||
.sort((left, right) => right.run.completedAt.localeCompare(left.run.completedAt) || left.run.id.localeCompare(right.run.id))
|
record => record.run.artifactId,
|
||||||
.filter((record, index, records) => records.findIndex(item => item.run.artifactId === record.run.artifactId) === index);
|
record => record.run.completedAt,
|
||||||
const activeProfileIds = new Set(activeProfiles.map(profile => profile.id));
|
record => record.run.id
|
||||||
|
);
|
||||||
|
const activeIdentityIds = new Set(identityAnalyses.map(record => record.run.id));
|
||||||
|
const aggregations = latestByKey(
|
||||||
|
input.aggregations.filter(record => activeProfileIds.has(record.run.environmentProfileId)
|
||||||
|
&& record.run.sourceAnalysisRunIds.length > 0
|
||||||
|
&& record.run.sourceAnalysisRunIds.every(runId => activeIdentityIds.has(runId))),
|
||||||
|
record => `${record.run.environmentProfileId}\u0000${record.run.snapshotId}`,
|
||||||
|
record => record.run.completedAt,
|
||||||
|
record => record.run.id
|
||||||
|
);
|
||||||
|
const activeAggregationIds = new Set(aggregations.map(record => record.run.id));
|
||||||
|
const evaluations = latestByKey(
|
||||||
|
input.evaluations.filter(record => activeAggregationIds.has(record.run.aggregationRunId)),
|
||||||
|
record => record.run.aggregationRunId,
|
||||||
|
record => record.run.completedAt,
|
||||||
|
record => record.run.id
|
||||||
|
);
|
||||||
|
const activeEvaluationIds = new Set(evaluations.map(record => record.run.id));
|
||||||
|
const findingRuns = latestByKey(
|
||||||
|
input.findingRuns.filter(record => activeEvaluationIds.has(record.run.evaluationRunId)),
|
||||||
|
record => record.run.evaluationRunId,
|
||||||
|
record => record.run.completedAt,
|
||||||
|
record => record.run.id
|
||||||
|
);
|
||||||
|
return { identityAnalyses, aggregations, evaluations, findingRuns };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePolicyRecords(
|
||||||
|
identityAnalyses: StoredGpoPolicyAnalysis[],
|
||||||
|
aggregations: StoredPolicySnapshotAggregation[],
|
||||||
|
evaluations: StoredPolicyEvaluation[],
|
||||||
|
findingRuns: StoredPolicyFindingRun[],
|
||||||
|
reviewItems: PolicyAnalysisReviewItem[]
|
||||||
|
): PolicyAnalysisSummary {
|
||||||
|
return {
|
||||||
|
identityAnalysisRuns: identityAnalyses.length,
|
||||||
|
aggregationRuns: aggregations.length,
|
||||||
|
evaluationRuns: evaluations.length,
|
||||||
|
findingRuns: findingRuns.length,
|
||||||
|
configuredMatches: evaluations.reduce((sum, record) => sum + record.run.matchCount, 0),
|
||||||
|
confirmedDeviations: evaluations.reduce((sum, record) => sum + record.run.deviationCount, 0),
|
||||||
|
notApplicable: evaluations.reduce((sum, record) => sum + record.run.notApplicableCount, 0),
|
||||||
|
policyFindings: findingRuns.reduce((sum, record) => sum + record.run.findingCount, 0),
|
||||||
|
reviewItems: reviewItems.length,
|
||||||
|
conflicts: reviewItems.filter(item => item.category === 'conflict').length,
|
||||||
|
unresolvedIdentities: reviewItems.filter(item => item.category === 'unresolved-identity').length,
|
||||||
|
catalogGaps: reviewItems.filter(item => item.category === 'catalog-gap').length,
|
||||||
|
parserGaps: reviewItems.filter(item => item.category === 'parser-gap').length,
|
||||||
|
partialRuns: identityAnalyses.filter(record => record.run.status === 'partial').length
|
||||||
|
+ aggregations.filter(record => record.run.status !== 'complete').length
|
||||||
|
+ evaluations.filter(record => record.run.status !== 'complete').length
|
||||||
|
+ findingRuns.filter(record => record.run.status === 'partial').length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisActions {
|
||||||
|
const active = activePolicyRecords(input);
|
||||||
|
const supersededProfileIds = new Set(input.profiles.map(profile => profile.supersedesProfileId).filter(Boolean));
|
||||||
|
const activeProfiles = input.profiles.filter(profile => !supersededProfileIds.has(profile.id));
|
||||||
|
const activeIdentityAnalyses = active.identityAnalyses;
|
||||||
const processingRuns = [...(input.processingRuns ?? [])]
|
const processingRuns = [...(input.processingRuns ?? [])]
|
||||||
.sort((left, right) => right.completedAt.localeCompare(left.completedAt) || left.id.localeCompare(right.id));
|
.sort((left, right) => right.completedAt.localeCompare(left.completedAt) || left.id.localeCompare(right.id));
|
||||||
const latestProcessingByArtifact = new Map<string, ImportProcessingRun>();
|
const latestProcessingByArtifact = new Map<string, ImportProcessingRun>();
|
||||||
|
|
@ -251,8 +340,7 @@ function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisAc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const profilesById = new Map(input.profiles.map(profile => [profile.id, profile]));
|
const profilesById = new Map(input.profiles.map(profile => [profile.id, profile]));
|
||||||
const evaluate: PolicyAnalysisEvaluationAction[] = input.aggregations
|
const evaluate: PolicyAnalysisEvaluationAction[] = active.aggregations.map(aggregation => {
|
||||||
.filter(aggregation => activeProfileIds.has(aggregation.run.environmentProfileId)).map(aggregation => {
|
|
||||||
const profile = profilesById.get(aggregation.run.environmentProfileId);
|
const profile = profilesById.get(aggregation.run.environmentProfileId);
|
||||||
const blocking: string[] = [];
|
const blocking: string[] = [];
|
||||||
const reviewOnly: string[] = [];
|
const reviewOnly: string[] = [];
|
||||||
|
|
@ -294,8 +382,7 @@ function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisAc
|
||||||
} as PolicyAnalysisEvaluationAction;
|
} as PolicyAnalysisEvaluationAction;
|
||||||
}).sort((left, right) => left.id.localeCompare(right.id));
|
}).sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
|
||||||
const createFindings: PolicyAnalysisFindingAction[] = input.evaluations
|
const createFindings: PolicyAnalysisFindingAction[] = active.evaluations.map(evaluation => {
|
||||||
.filter(evaluation => activeProfileIds.has(evaluation.run.environmentProfileId)).map(evaluation => {
|
|
||||||
const existing = input.findingRuns.find(record => record.run.evaluationRunId === evaluation.run.id);
|
const existing = input.findingRuns.find(record => record.run.evaluationRunId === evaluation.run.id);
|
||||||
const eligibleResultCount = evaluation.results.filter(result => result.findingEligibility === 'eligible').length;
|
const eligibleResultCount = evaluation.results.filter(result => result.findingEligibility === 'eligible').length;
|
||||||
const reasons = evaluation.run.status === 'blocked' ? ['policy-action-evaluation-blocked']
|
const reasons = evaluation.run.status === 'blocked' ? ['policy-action-evaluation-blocked']
|
||||||
|
|
@ -402,7 +489,12 @@ export function buildPolicyAnalysisOverview(input: BuildPolicyAnalysisOverviewIn
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewItems = reviews.sort((left, right) => left.id.localeCompare(right.id));
|
const reviewItems = reviews.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
const findingByEvaluation = new Map(input.findingRuns.map(record => [record.run.evaluationRunId, record]));
|
const findingByEvaluation = new Map(latestByKey(
|
||||||
|
input.findingRuns,
|
||||||
|
record => record.run.evaluationRunId,
|
||||||
|
record => record.run.completedAt,
|
||||||
|
record => record.run.id
|
||||||
|
).map(record => [record.run.evaluationRunId, record]));
|
||||||
const evaluations = input.evaluations.map(record => {
|
const evaluations = input.evaluations.map(record => {
|
||||||
const finding = findingByEvaluation.get(record.run.id);
|
const finding = findingByEvaluation.get(record.run.id);
|
||||||
return {
|
return {
|
||||||
|
|
@ -422,30 +514,30 @@ export function buildPolicyAnalysisOverview(input: BuildPolicyAnalysisOverviewIn
|
||||||
};
|
};
|
||||||
}).sort((left, right) => right.completedAt.localeCompare(left.completedAt)
|
}).sort((left, right) => right.completedAt.localeCompare(left.completedAt)
|
||||||
|| left.evaluationRunId.localeCompare(right.evaluationRunId));
|
|| left.evaluationRunId.localeCompare(right.evaluationRunId));
|
||||||
const partialRuns = input.identityAnalyses.filter(record => record.run.status === 'partial').length
|
const active = activePolicyRecords(input);
|
||||||
+ input.aggregations.filter(record => record.run.status !== 'complete').length
|
const activeRunIds = new Set([
|
||||||
+ input.evaluations.filter(record => record.run.status !== 'complete').length
|
...active.identityAnalyses.map(record => record.run.id),
|
||||||
+ input.findingRuns.filter(record => record.run.status === 'partial').length;
|
...active.aggregations.map(record => record.run.id),
|
||||||
|
...active.evaluations.map(record => record.run.id),
|
||||||
|
...active.findingRuns.map(record => record.run.id)
|
||||||
|
]);
|
||||||
|
const currentReviewItems = reviewItems.filter(item => activeRunIds.has(item.sourceRunId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
generatedAt: input.generatedAt,
|
generatedAt: input.generatedAt,
|
||||||
summary: {
|
summary: summarizePolicyRecords(
|
||||||
identityAnalysisRuns: input.identityAnalyses.length,
|
input.identityAnalyses, input.aggregations, input.evaluations, input.findingRuns, reviewItems
|
||||||
aggregationRuns: input.aggregations.length,
|
),
|
||||||
evaluationRuns: input.evaluations.length,
|
current: {
|
||||||
findingRuns: input.findingRuns.length,
|
version: 1,
|
||||||
configuredMatches: input.evaluations.reduce((sum, record) => sum + record.run.matchCount, 0),
|
summary: summarizePolicyRecords(
|
||||||
confirmedDeviations: input.evaluations.reduce((sum, record) => sum + record.run.deviationCount, 0),
|
active.identityAnalyses, active.aggregations, active.evaluations, active.findingRuns, currentReviewItems
|
||||||
notApplicable: input.evaluations.reduce((sum, record) => sum + record.run.notApplicableCount, 0),
|
),
|
||||||
policyFindings: input.findingRuns.reduce((sum, record) => sum + record.run.findingCount, 0),
|
evaluationRunIds: uniqueSorted(active.evaluations.map(record => record.run.id)),
|
||||||
reviewItems: reviewItems.length,
|
findingRunIds: uniqueSorted(active.findingRuns.map(record => record.run.id)),
|
||||||
conflicts: reviewItems.filter(item => item.category === 'conflict').length,
|
reviewItems: currentReviewItems
|
||||||
unresolvedIdentities: reviewItems.filter(item => item.category === 'unresolved-identity').length,
|
|
||||||
catalogGaps: reviewItems.filter(item => item.category === 'catalog-gap').length,
|
|
||||||
parserGaps: reviewItems.filter(item => item.category === 'parser-gap').length,
|
|
||||||
partialRuns
|
|
||||||
},
|
},
|
||||||
actions: buildActions(input),
|
actions: buildActions(input),
|
||||||
profiles: input.profiles.map(profile => ({
|
profiles: input.profiles.map(profile => ({
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,16 @@ import type {
|
||||||
} from './canonical-model';
|
} from './canonical-model';
|
||||||
import type { PolicyFindingOccurrence } from './policy-finding';
|
import type { PolicyFindingOccurrence } from './policy-finding';
|
||||||
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
|
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
|
||||||
|
import type { ImportArtifact, ImportProcessingRun } from './ingestion-persistence';
|
||||||
|
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis, StoredGpoPolicyIdentityResolution } from './gpo-analysis-persistence';
|
||||||
|
import type { GpoPolicyObservation } from './gpo-policy';
|
||||||
import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-contract-mapping';
|
import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-contract-mapping';
|
||||||
import { isDeviation } from './deviation-predicate';
|
import { isDeviation } from './deviation-predicate';
|
||||||
import { severityRank } from './severity-order';
|
import { severityRank } from './severity-order';
|
||||||
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
|
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
|
||||||
import type { CveEnrichmentRecord } from './cve-enrichment';
|
import type { CveEnrichmentRecord } from './cve-enrichment';
|
||||||
|
|
||||||
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'environment-observation';
|
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation';
|
||||||
export type UnifiedOutcome = CanonicalOutcome;
|
export type UnifiedOutcome = CanonicalOutcome;
|
||||||
export type TechnicalExecutionState = CanonicalTechnicalExecutionState;
|
export type TechnicalExecutionState = CanonicalTechnicalExecutionState;
|
||||||
export type UnifiedReviewStatus = 'unreviewed' | 'open' | 'in-progress' | 'resolved';
|
export type UnifiedReviewStatus = 'unreviewed' | 'open' | 'in-progress' | 'resolved';
|
||||||
|
|
@ -80,6 +83,14 @@ export interface UnifiedResultProjection {
|
||||||
environmentNormalizedValue?: JsonValue;
|
environmentNormalizedValue?: JsonValue;
|
||||||
environmentSectionKey?: string;
|
environmentSectionKey?: string;
|
||||||
environmentAttributes?: Record<string, JsonValue>;
|
environmentAttributes?: Record<string, JsonValue>;
|
||||||
|
policyScope?: GpoPolicyObservation['scope'];
|
||||||
|
policyDeliveryMechanism?: GpoPolicyObservation['deliveryMechanism'];
|
||||||
|
policyState?: GpoPolicyObservation['state'];
|
||||||
|
policyIdentityStatus?: GpoPolicyIdentityResolutionStatus;
|
||||||
|
policyIdentityKey?: string;
|
||||||
|
policyValues?: JsonValue[];
|
||||||
|
policyDiagnosticCodes?: string[];
|
||||||
|
policyBackupInstanceId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
|
|
@ -128,6 +139,7 @@ export interface UnifiedResultProjection {
|
||||||
export interface UnifiedResultQuery {
|
export interface UnifiedResultQuery {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assessmentId?: string;
|
assessmentId?: string;
|
||||||
|
assessmentIds?: string[];
|
||||||
scanRunId?: string;
|
scanRunId?: string;
|
||||||
scanRunIds?: string[];
|
scanRunIds?: string[];
|
||||||
evaluationRunId?: string;
|
evaluationRunId?: string;
|
||||||
|
|
@ -207,6 +219,15 @@ export interface EnvironmentUnifiedResultSource {
|
||||||
observation: EnvironmentObservation;
|
observation: EnvironmentObservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GpoPolicyIdentityResolutionStatus = StoredGpoPolicyIdentityResolution['resolution']['status'];
|
||||||
|
|
||||||
|
export interface PolicyObservationUnifiedResultSource {
|
||||||
|
artifact: ImportArtifact;
|
||||||
|
backup: StoredGpoBackupInstance;
|
||||||
|
observation: GpoPolicyObservation;
|
||||||
|
resolution?: StoredGpoPolicyIdentityResolution;
|
||||||
|
}
|
||||||
|
|
||||||
export function outcomeFromLegacyStatus(status: CanonicalFindingStatus): UnifiedOutcome {
|
export function outcomeFromLegacyStatus(status: CanonicalFindingStatus): UnifiedOutcome {
|
||||||
if (status === 'failed') return 'fail';
|
if (status === 'failed') return 'fail';
|
||||||
if (status === 'passed') return 'pass';
|
if (status === 'passed') return 'pass';
|
||||||
|
|
@ -423,6 +444,137 @@ export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): Un
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function policyObservationTitle(observation: GpoPolicyObservation): string {
|
||||||
|
const section = observation.identity.section.trim();
|
||||||
|
const key = observation.identity.key.trim();
|
||||||
|
return [section, key].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyObservationLocator(observation: GpoPolicyObservation): SourcePosition {
|
||||||
|
const suffix = observation.sourceLines?.length
|
||||||
|
? `#lines=${observation.sourceLines.join(',')}`
|
||||||
|
: observation.sourceRecords?.length
|
||||||
|
? `#records=${observation.sourceRecords.map(record => record.index).join(',')}`
|
||||||
|
: '';
|
||||||
|
return { objectPath: observation.sourcePath + suffix };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects a parsed GPO setting as reviewable evidence. This is deliberately
|
||||||
|
* not a policy deviation: only the Finding Factory may create one after a
|
||||||
|
* complete baseline evaluation. The projection makes imported GPO content
|
||||||
|
* visible without weakening that safety boundary.
|
||||||
|
*/
|
||||||
|
export function createPolicyObservationUnifiedResult(source: PolicyObservationUnifiedResultSource): UnifiedResultProjection {
|
||||||
|
const { artifact, backup, observation, resolution } = source;
|
||||||
|
const resolved = resolution?.resolution.status === 'resolved' ? resolution.resolution.identity : undefined;
|
||||||
|
const unresolvedReason = resolution?.resolution.status === 'unresolved'
|
||||||
|
? resolution.resolution.reasonCode
|
||||||
|
: 'policy-identity-resolution-missing';
|
||||||
|
const policyIdentityKey = resolved?.id
|
||||||
|
?? `${observation.identity.namespace}:${observation.identity.section}:${observation.identity.key}`;
|
||||||
|
const diagnosticCodes = [...new Set([
|
||||||
|
...observation.diagnosticCodes,
|
||||||
|
...(resolved ? [] : [unresolvedReason])
|
||||||
|
])].sort();
|
||||||
|
const needsReview = !resolved || observation.state === 'conflicted' || diagnosticCodes.length > 0;
|
||||||
|
return {
|
||||||
|
resultProjectionId: `policy-observation:${observation.id}`,
|
||||||
|
sourceKind: 'policy-observation',
|
||||||
|
sourceResultId: observation.id,
|
||||||
|
projectId: backup.projectId,
|
||||||
|
assessmentId: artifact.assessmentId,
|
||||||
|
collectionRunId: backup.processingRunId,
|
||||||
|
sourceTool: 'group-policy',
|
||||||
|
sourceProfileId: observation.parser.id,
|
||||||
|
sourceProfileVersion: observation.parser.version,
|
||||||
|
mappingRuleVersion: resolved?.resolver.version ?? resolution?.analysisRunId ?? 'unresolved',
|
||||||
|
normalizationRuleVersion: observation.reducer.version,
|
||||||
|
presentationVersion: 'gpo-policy-observation-1.0.0',
|
||||||
|
processingCompatibilityKey: 'gpo-policy-observation-v1',
|
||||||
|
processingStatus: 'current',
|
||||||
|
reprocessingAvailable: true,
|
||||||
|
sourceRuleId: resolved?.id,
|
||||||
|
importArtifactId: artifact.id,
|
||||||
|
sourceViewId: backup.id,
|
||||||
|
sourceLocator: policyObservationLocator(observation),
|
||||||
|
title: policyObservationTitle(observation),
|
||||||
|
description: resolved
|
||||||
|
? 'Imported Group Policy setting with a resolved technical identity.'
|
||||||
|
: `Imported Group Policy setting requiring identity review (${unresolvedReason}).`,
|
||||||
|
severityOriginal: null,
|
||||||
|
severityNormalized: 'unknown',
|
||||||
|
outcomeOriginal: observation.state,
|
||||||
|
outcomeNormalized: needsReview ? 'warning' : 'informational',
|
||||||
|
technicalExecutionState: 'completed',
|
||||||
|
resultType: 'informational',
|
||||||
|
isIoe: false,
|
||||||
|
isDeviation: false,
|
||||||
|
effectiveState: resolved ? 'not_evaluated' : 'unresolved_identity',
|
||||||
|
lifecycleState: 'unknown',
|
||||||
|
mappingConfidence: resolved ? 1 : 0,
|
||||||
|
completenessState: backup.instance.inventory.status,
|
||||||
|
scopeState: 'unknown',
|
||||||
|
effectivenessVerification: 'not-verified',
|
||||||
|
scopeConfidence: 0,
|
||||||
|
reviewStatus: needsReview ? 'open' : 'unreviewed',
|
||||||
|
sourceObservedAt: backup.instance.backupCreatedAt ?? backup.createdAt,
|
||||||
|
importedAt: artifact.createdAt,
|
||||||
|
createdAt: backup.createdAt,
|
||||||
|
policyScope: observation.scope,
|
||||||
|
policyDeliveryMechanism: observation.deliveryMechanism,
|
||||||
|
policyState: observation.state,
|
||||||
|
policyIdentityStatus: resolved ? 'resolved' : 'unresolved',
|
||||||
|
policyIdentityKey,
|
||||||
|
policyValues: observation.values as unknown as JsonValue[],
|
||||||
|
policyDiagnosticCodes: diagnosticCodes,
|
||||||
|
policyBackupInstanceId: backup.instance.id,
|
||||||
|
categories: [{
|
||||||
|
namespace: 'group-policy',
|
||||||
|
categoryCode: observation.deliveryMechanism,
|
||||||
|
categoryLabel: observation.deliveryMechanism,
|
||||||
|
categoryType: 'source',
|
||||||
|
isPrimary: true,
|
||||||
|
sourceTool: 'group-policy',
|
||||||
|
sourceProfileId: observation.parser.id,
|
||||||
|
sourceProfileVersion: observation.parser.version
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentPolicyObservationUnifiedResults(
|
||||||
|
artifacts: ImportArtifact[],
|
||||||
|
processingRuns: ImportProcessingRun[],
|
||||||
|
backups: StoredGpoBackupInstance[],
|
||||||
|
analyses: StoredGpoPolicyAnalysis[]
|
||||||
|
): UnifiedResultProjection[] {
|
||||||
|
const artifactById = new Map(artifacts.map(artifact => [artifact.id, artifact]));
|
||||||
|
const supersededProcessingIds = new Set(processingRuns.map(run => run.supersedesRunId).filter(Boolean));
|
||||||
|
const currentProcessingIds = new Set(processingRuns
|
||||||
|
.filter(run => !supersededProcessingIds.has(run.id) && run.status !== 'failed')
|
||||||
|
.map(run => run.id));
|
||||||
|
const supersededAnalysisIds = new Set(analyses.map(record => record.run.supersedesAnalysisRunId).filter(Boolean));
|
||||||
|
const resolutionByObservation = new Map<string, StoredGpoPolicyIdentityResolution>();
|
||||||
|
for (const analysis of analyses) {
|
||||||
|
if (supersededAnalysisIds.has(analysis.run.id) || !currentProcessingIds.has(analysis.run.sourceProcessingRunId)) continue;
|
||||||
|
for (const resolution of analysis.resolutions) resolutionByObservation.set(resolution.observationId, resolution);
|
||||||
|
}
|
||||||
|
return backups
|
||||||
|
.filter(backup => currentProcessingIds.has(backup.processingRunId))
|
||||||
|
.flatMap(backup => {
|
||||||
|
const artifact = artifactById.get(backup.artifactId);
|
||||||
|
if (!artifact) return [];
|
||||||
|
return (backup.instance.policyObservations ?? []).map(observation => createPolicyObservationUnifiedResult({
|
||||||
|
artifact,
|
||||||
|
backup,
|
||||||
|
observation,
|
||||||
|
resolution: resolutionByObservation.get(observation.id)
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.importedAt.localeCompare(right.importedAt)
|
||||||
|
|| left.resultProjectionId.localeCompare(right.resultProjectionId));
|
||||||
|
}
|
||||||
|
|
||||||
function environmentOutcome(state: EnvironmentCollectionState): UnifiedOutcome {
|
function environmentOutcome(state: EnvironmentCollectionState): UnifiedOutcome {
|
||||||
if (state === 'parse-failed') return 'execution_error';
|
if (state === 'parse-failed') return 'execution_error';
|
||||||
if (['not-assessed', 'access-denied', 'license-restricted', 'unsupported', 'unknown'].includes(state)) return 'not_evaluated';
|
if (['not-assessed', 'access-denied', 'license-restricted', 'unsupported', 'unknown'].includes(state)) return 'not_evaluated';
|
||||||
|
|
@ -530,8 +682,10 @@ export function filterUnifiedResults(items: UnifiedResultProjection[], query: Un
|
||||||
return items.filter(item => {
|
return items.filter(item => {
|
||||||
if (query.projectId && item.projectId !== query.projectId) return false;
|
if (query.projectId && item.projectId !== query.projectId) return false;
|
||||||
if (query.assessmentId && item.assessmentId !== query.assessmentId) return false;
|
if (query.assessmentId && item.assessmentId !== query.assessmentId) return false;
|
||||||
|
if (query.assessmentIds?.length && (!item.assessmentId || !query.assessmentIds.includes(item.assessmentId))) return false;
|
||||||
if (query.scanRunId && item.scanRunId !== query.scanRunId) return false;
|
if (query.scanRunId && item.scanRunId !== query.scanRunId) return false;
|
||||||
if (query.scanRunIds?.length && (!item.scanRunId || !query.scanRunIds.includes(item.scanRunId))) return false;
|
if (query.scanRunIds?.length && item.scanRunId && !query.scanRunIds.includes(item.scanRunId)) return false;
|
||||||
|
if (query.scanRunIds?.length && !item.scanRunId && !query.assessmentIds?.includes(item.assessmentId ?? '')) return false;
|
||||||
if (query.evaluationRunId && item.evaluationRunId !== query.evaluationRunId) return false;
|
if (query.evaluationRunId && item.evaluationRunId !== query.evaluationRunId) return false;
|
||||||
if (!includesAny(query.sourceKind, item.sourceKind)) return false;
|
if (!includesAny(query.sourceKind, item.sourceKind)) return false;
|
||||||
if (sourceTools?.length && (!item.sourceTool || !sourceTools.includes(normalizedText(item.sourceTool)))) return false;
|
if (sourceTools?.length && (!item.sourceTool || !sourceTools.includes(normalizedText(item.sourceTool)))) return false;
|
||||||
|
|
@ -540,7 +694,7 @@ export function filterUnifiedResults(items: UnifiedResultProjection[], query: Un
|
||||||
if (!includesAny(query.technicalExecutionState, item.technicalExecutionState)) return false;
|
if (!includesAny(query.technicalExecutionState, item.technicalExecutionState)) return false;
|
||||||
if (!includesAny(query.resultType, item.resultType)) return false;
|
if (!includesAny(query.resultType, item.resultType)) return false;
|
||||||
if (query.isIoe !== undefined && item.isIoe !== query.isIoe) return false;
|
if (query.isIoe !== undefined && item.isIoe !== query.isIoe) return false;
|
||||||
if (query.deviation !== undefined && item.isDeviation !== query.deviation) return false;
|
if (query.deviation !== undefined && item.sourceKind !== 'policy-observation' && item.isDeviation !== query.deviation) return false;
|
||||||
if (!includesAny(query.mappingRuleVersion, item.mappingRuleVersion)) return false;
|
if (!includesAny(query.mappingRuleVersion, item.mappingRuleVersion)) return false;
|
||||||
if (!includesAny(query.sourceProfileVersion, item.sourceProfileVersion)) return false;
|
if (!includesAny(query.sourceProfileVersion, item.sourceProfileVersion)) return false;
|
||||||
if (!includesAny(query.processingStatus, item.processingStatus)) return false;
|
if (!includesAny(query.processingStatus, item.processingStatus)) return false;
|
||||||
|
|
@ -568,6 +722,13 @@ export function filterUnifiedResults(items: UnifiedResultProjection[], query: Un
|
||||||
environmentState: item.environmentState,
|
environmentState: item.environmentState,
|
||||||
environmentOriginalValue: item.environmentOriginalValue,
|
environmentOriginalValue: item.environmentOriginalValue,
|
||||||
environmentNormalizedValue: item.environmentNormalizedValue,
|
environmentNormalizedValue: item.environmentNormalizedValue,
|
||||||
|
policyScope: item.policyScope,
|
||||||
|
policyDeliveryMechanism: item.policyDeliveryMechanism,
|
||||||
|
policyState: item.policyState,
|
||||||
|
policyIdentityStatus: item.policyIdentityStatus,
|
||||||
|
policyIdentityKey: item.policyIdentityKey,
|
||||||
|
policyValues: item.policyValues,
|
||||||
|
policyDiagnosticCodes: item.policyDiagnosticCodes,
|
||||||
categories: item.categories.map(category => category.categoryLabel)
|
categories: item.categories.map(category => category.categoryLabel)
|
||||||
}));
|
}));
|
||||||
if (!text.includes(search)) return false;
|
if (!text.includes(search)) return false;
|
||||||
|
|
@ -601,6 +762,7 @@ function scopeOnlyQuery(query: UnifiedResultQuery): UnifiedResultQuery {
|
||||||
return {
|
return {
|
||||||
projectId: query.projectId,
|
projectId: query.projectId,
|
||||||
assessmentId: query.assessmentId,
|
assessmentId: query.assessmentId,
|
||||||
|
assessmentIds: query.assessmentIds,
|
||||||
scanRunId: query.scanRunId,
|
scanRunId: query.scanRunId,
|
||||||
scanRunIds: query.scanRunIds,
|
scanRunIds: query.scanRunIds,
|
||||||
evaluationRunId: query.evaluationRunId
|
evaluationRunId: query.evaluationRunId
|
||||||
|
|
@ -668,6 +830,9 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
|
||||||
return { id: 'category', label: 'Categories', values };
|
return { id: 'category', label: 'Categories', values };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findingAxis = (item: UnifiedResultProjection): boolean =>
|
||||||
|
item.sourceKind !== 'environment-observation' && item.sourceKind !== 'policy-observation';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
facets: [
|
facets: [
|
||||||
|
|
@ -676,16 +841,16 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
|
||||||
facet('environmentState', 'Environment State', item => item.environmentState, 'environmentState'),
|
facet('environmentState', 'Environment State', item => item.environmentState, 'environmentState'),
|
||||||
facet('environmentSubjectType', 'Environment Subject', item => item.environmentSubjectType, 'environmentSubjectType'),
|
facet('environmentSubjectType', 'Environment Subject', item => item.environmentSubjectType, 'environmentSubjectType'),
|
||||||
facet('environmentProperty', 'Environment Property', item => item.environmentProperty, 'environmentProperty'),
|
facet('environmentProperty', 'Environment Property', item => item.environmentProperty, 'environmentProperty'),
|
||||||
facet('severity', 'Severity', item => item.sourceKind === 'environment-observation' ? undefined : item.severityNormalized, 'severity'),
|
facet('severity', 'Severity', item => findingAxis(item) ? item.severityNormalized : undefined, 'severity'),
|
||||||
facet('outcome', 'Outcome', item => item.sourceKind === 'environment-observation' ? undefined : item.outcomeNormalized, 'outcome'),
|
facet('outcome', 'Outcome', item => findingAxis(item) ? item.outcomeNormalized : undefined, 'outcome'),
|
||||||
facet('technicalExecutionState', 'Execution', item => item.sourceKind === 'environment-observation' ? undefined : item.technicalExecutionState, 'technicalExecutionState'),
|
facet('technicalExecutionState', 'Execution', item => findingAxis(item) ? item.technicalExecutionState : undefined, 'technicalExecutionState'),
|
||||||
facet('resultType', 'Result Type', item => item.sourceKind === 'environment-observation' ? undefined : item.resultType, 'resultType'),
|
facet('resultType', 'Result Type', item => findingAxis(item) ? item.resultType : undefined, 'resultType'),
|
||||||
facet('isIoe', 'IOE', item => item.sourceKind === 'environment-observation' ? undefined : String(item.isIoe), 'isIoe'),
|
facet('isIoe', 'IOE', item => findingAxis(item) ? String(item.isIoe) : undefined, 'isIoe'),
|
||||||
facet('deviation', 'Deviation', item => item.sourceKind === 'environment-observation' ? undefined : String(item.isDeviation), 'deviation'),
|
facet('deviation', 'Deviation', item => findingAxis(item) ? String(item.isDeviation) : undefined, 'deviation'),
|
||||||
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
|
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
|
||||||
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
|
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
|
||||||
facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'),
|
facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'),
|
||||||
facet('reviewStatus', 'Review Status', item => item.sourceKind === 'environment-observation' ? undefined : item.reviewStatus),
|
facet('reviewStatus', 'Review Status', item => findingAxis(item) ? item.reviewStatus : undefined),
|
||||||
facet('reviewDisposition', 'Review Disposition', item => item.reviewDisposition, 'reviewDisposition'),
|
facet('reviewDisposition', 'Review Disposition', item => item.reviewDisposition, 'reviewDisposition'),
|
||||||
facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'),
|
facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'),
|
||||||
facet('effectiveState', 'Effectiveness', item => item.effectiveState),
|
facet('effectiveState', 'Effectiveness', item => item.effectiveState),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type {
|
||||||
|
UnifiedResultProjection,
|
||||||
|
UnifiedResultQuery,
|
||||||
|
UnifiedResultQueryResult
|
||||||
|
} from '../domain/unified-result';
|
||||||
|
|
||||||
|
export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl';
|
||||||
|
export const RESULT_DB_JSONL_SCHEMA_VERSION = '1.0.0';
|
||||||
|
export const RESULT_DB_EXPORT_PAGE_SIZE = 1000;
|
||||||
|
|
||||||
|
type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>;
|
||||||
|
|
||||||
|
function definedQueryEntries(query: UnifiedResultQuery): Array<[string, unknown]> {
|
||||||
|
return Object.entries(query).filter(([, value]) => value !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportScope(query: UnifiedResultQuery): Record<string, unknown> {
|
||||||
|
const scopeKeys = new Set(['projectId', 'assessmentId', 'scanRunId', 'scanRunIds', 'evaluationRunId']);
|
||||||
|
return Object.fromEntries(definedQueryEntries(query).filter(([key]) => scopeKeys.has(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFilters(query: UnifiedResultQuery): Record<string, unknown> {
|
||||||
|
const excluded = new Set(['projectId', 'assessmentId', 'scanRunId', 'scanRunIds', 'evaluationRunId', 'offset', 'limit']);
|
||||||
|
return Object.fromEntries(definedQueryEntries(query).filter(([key]) => !excluded.has(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnifiedResultJsonl(
|
||||||
|
query: UnifiedResultQuery,
|
||||||
|
results: UnifiedResultProjection[],
|
||||||
|
generatedAt: string
|
||||||
|
): string {
|
||||||
|
const lines = [{
|
||||||
|
recordType: 'manifest',
|
||||||
|
format: RESULT_DB_JSONL_FORMAT,
|
||||||
|
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
|
generatedAt,
|
||||||
|
resultCount: results.length,
|
||||||
|
scope: exportScope(query),
|
||||||
|
filters: exportFilters(query)
|
||||||
|
}, ...results.map((result, index) => ({
|
||||||
|
recordType: 'result',
|
||||||
|
position: index + 1,
|
||||||
|
result
|
||||||
|
}))];
|
||||||
|
return `${lines.map(line => JSON.stringify(line)).join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unifiedResultJsonlFileName(generatedAt: string): string {
|
||||||
|
const timestamp = generatedAt.replace(/\.\d{3}Z$/, 'Z').replaceAll(':', '-');
|
||||||
|
return `assessor-result-db-filtered-${timestamp}.jsonl`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAllUnifiedResults(
|
||||||
|
readPage: ResultPageReader,
|
||||||
|
query: UnifiedResultQuery,
|
||||||
|
pageSize = RESULT_DB_EXPORT_PAGE_SIZE
|
||||||
|
): Promise<UnifiedResultProjection[]> {
|
||||||
|
const baseQuery = { ...query, offset: undefined, limit: undefined };
|
||||||
|
const results: UnifiedResultProjection[] = [];
|
||||||
|
let expectedTotal: number | undefined;
|
||||||
|
|
||||||
|
while (expectedTotal === undefined || results.length < expectedTotal) {
|
||||||
|
const page = await readPage({ ...baseQuery, offset: results.length, limit: pageSize });
|
||||||
|
if (expectedTotal === undefined) expectedTotal = page.total;
|
||||||
|
if (page.total !== expectedTotal) throw new Error('The filtered result set changed during export. Please retry.');
|
||||||
|
if (!page.items.length && results.length < expectedTotal) throw new Error('The filtered result export ended before all rows were returned.');
|
||||||
|
results.push(...page.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length !== expectedTotal) throw new Error('The filtered result export returned an inconsistent row count.');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
import { useEffect, useId, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import { useApp } from '../../app/AppProvider';
|
import { useApp } from '../../app/AppProvider';
|
||||||
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
||||||
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
|
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
|
||||||
import type { FindingRecord } from '../../domain/finding';
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
import type { ImportProfile } from '../../domain/import-profile';
|
import type { ImportProfile } from '../../domain/import-profile';
|
||||||
|
|
@ -401,6 +402,8 @@ interface DashboardEnvironmentMetric {
|
||||||
value: number;
|
value: number;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
restricted?: boolean;
|
restricted?: boolean;
|
||||||
|
tone?: string;
|
||||||
|
onClick?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardEnvironmentSummary {
|
interface DashboardEnvironmentSummary {
|
||||||
|
|
@ -461,8 +464,7 @@ export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): Das
|
||||||
{ label: 'Domain FL', value: observationText(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
{ label: 'Domain FL', value: observationText(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||||
{ label: 'Forest FL', value: observationText(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
{ label: 'Forest FL', value: observationText(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||||
{ label: 'Schema', value: observationText(snapshot, 'forest.schema_version', 'Schema version') },
|
{ label: 'Schema', value: observationText(snapshot, 'forest.schema_version', 'Schema version') },
|
||||||
{ label: 'Domain Controllers', value: observationText(snapshot, 'domain.controller_count', 'DC count') },
|
{ label: 'Domain Controllers', value: observationText(snapshot, 'domain.controller_count', 'DC count') }
|
||||||
{ label: 'PingCastle', value: observationText(snapshot, 'report.engine_version', 'PingCastle engine version') }
|
|
||||||
],
|
],
|
||||||
coverage: [
|
coverage: [
|
||||||
{ label: 'Observed', value: snapshot?.coverage.observed ?? 0 },
|
{ label: 'Observed', value: snapshot?.coverage.observed ?? 0 },
|
||||||
|
|
@ -523,18 +525,23 @@ interface DashboardMetricTileProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
|
emphasis?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onClick?(): void;
|
onClick?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardMetricTile({ label, value, tone = 'neutral', active, onClick }: DashboardMetricTileProps) {
|
function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false, active, onClick }: DashboardMetricTileProps) {
|
||||||
const content = <><span>{label}</span><strong>{value}</strong></>;
|
const content = <><span>{label}</span><strong>{value}</strong></>;
|
||||||
const className = `client-info-metric tone-${tone}`;
|
const className = `client-info-metric tone-${tone}${emphasis ? ' is-emphasis' : ''}`;
|
||||||
return onClick
|
return onClick
|
||||||
? <button className={className} type="button" aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
|
? <button className={className} type="button" aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
|
||||||
: <article className={className}>{content}</article>;
|
: <article className={className}>{content}</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DashboardFactList({ items }: { items: Array<{ label: string; value: string }> }) {
|
||||||
|
return <dl className="client-info-facts">{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>;
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardDonutSegment {
|
interface DashboardDonutSegment {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -566,22 +573,59 @@ function DashboardDonut({ total, centerLabel, segments }: { total: number; cente
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardFactList({ items }: { items: Array<{ label: string; value: string }> }) {
|
export function DashboardMetricBars({ items, tone = 'accent' }: { items: DashboardEnvironmentMetric[]; tone?: string }) {
|
||||||
return <dl className="client-info-facts">{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardMetricBars({ items, tone = 'accent' }: { items: DashboardEnvironmentMetric[]; tone?: string }) {
|
|
||||||
const maximum = Math.max(1, ...items.map(item => item.value));
|
const maximum = Math.max(1, ...items.map(item => item.value));
|
||||||
return <ul className="client-info-bars">{items.map(item => <li key={item.label}>
|
const ordinalTones = ['critical', 'high', 'medium', 'accent', 'neutral'];
|
||||||
<span>{item.label}</span>
|
return <ul className="client-info-bars">{items.map((item, index) => {
|
||||||
<span className="client-info-bar-track"><i className={`tone-${tone}`} style={shareStyle(share(item.value, maximum))} /></span>
|
const resolvedTone = item.tone ?? (tone === 'accent' ? ordinalTones[index % ordinalTones.length] : tone);
|
||||||
<strong>{item.value.toLocaleString('en-US')}{item.detail ? ` ${item.detail}` : ''}</strong>
|
const content = <>
|
||||||
{item.restricted && <small title="Some source details are restricted">Restricted</small>}
|
<span className="client-info-bar-label">{item.label}</span>
|
||||||
</li>)}</ul>;
|
<strong><span>{item.value.toLocaleString('en-US')}</span>{item.detail && <small className="client-info-bar-detail">{item.detail}</small>}</strong>
|
||||||
|
<small className={`client-info-bar-status${item.restricted ? '' : ' is-empty'}`} title={item.restricted ? 'Some source details are restricted' : undefined} aria-hidden={!item.restricted}>{item.restricted ? 'Restricted' : '\u00a0'}</small>
|
||||||
|
<span className="client-info-bar-track"><i className={`tone-${resolvedTone}`} style={shareStyle(share(item.value, maximum))} /></span>
|
||||||
|
</>;
|
||||||
|
return <li key={item.label}>{item.onClick
|
||||||
|
? <button className="client-info-bar-row" type="button" onClick={item.onClick}>{content}</button>
|
||||||
|
: <div className="client-info-bar-row">{content}</div>}
|
||||||
|
</li>;
|
||||||
|
})}</ul>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardLargeCard({ title, meta, className = '', children }: { title: string; meta?: string; className?: string; children: ReactNode }) {
|
interface DashboardCardSummary {
|
||||||
return <section className={`client-info-card ${className}`.trim()}><header><h2>{title}</h2>{meta && <span>{meta}</span>}</header>{children}</section>;
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLargeCard({ title, meta, summary, className = '', details, initiallyExpanded = true, children }: { title: string; meta?: string; summary?: DashboardCardSummary; className?: string; details?: ReactNode; initiallyExpanded?: boolean; children?: ReactNode }) {
|
||||||
|
const [expanded, setExpanded] = useState(initiallyExpanded);
|
||||||
|
const detailsId = useId();
|
||||||
|
return <section className={`client-info-card client-info-detail-card ${expanded ? 'is-expanded' : 'is-collapsed'} ${className}`.trim()}>
|
||||||
|
<header>
|
||||||
|
<span className="client-info-card-heading"><h2>{title}</h2>{summary && <span className="client-info-card-primary">— {summary.label} {typeof summary.value === 'number' ? summary.value.toLocaleString('en-US') : summary.value}{summary.detail ? ` ${summary.detail}` : ''}</span>}{meta && <span className="client-info-card-meta">{meta}</span>}</span>
|
||||||
|
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
|
||||||
|
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}
|
||||||
|
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'} /></button>
|
||||||
|
</header>
|
||||||
|
{expanded && <div className="client-info-card-content" id={detailsId}>
|
||||||
|
<div className="client-info-card-details">{children}</div>
|
||||||
|
{details && <div className="client-info-card-extra">{details}</div>}
|
||||||
|
</div>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardDonutCard({ title, meta, initiallyExpanded = true, children }: { title: string; meta: string; initiallyExpanded?: boolean; children: ReactNode }) {
|
||||||
|
const [expanded, setExpanded] = useState(initiallyExpanded);
|
||||||
|
const detailsId = useId();
|
||||||
|
return <section className={`client-info-card client-info-donut-card ${expanded ? 'is-expanded' : 'is-collapsed'}`}>
|
||||||
|
<header>
|
||||||
|
<span className="client-info-card-heading"><h2>{title}</h2><span className="client-info-card-primary">— {meta}</span></span>
|
||||||
|
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
|
||||||
|
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}
|
||||||
|
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'} /></button>
|
||||||
|
</header>
|
||||||
|
{expanded && <div className="client-info-card-content" id={detailsId}><div className="client-info-card-details">{children}</div></div>}
|
||||||
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
|
|
@ -644,6 +688,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const explicitIoes = explicitIoeRecordIds.length;
|
const explicitIoes = explicitIoeRecordIds.length;
|
||||||
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
|
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
|
||||||
const highSeverity = severityFacet?.values.find(value => value.value === 'high')?.count ?? 0;
|
const highSeverity = severityFacet?.values.find(value => value.value === 'high')?.count ?? 0;
|
||||||
|
const classified = severityDistributionValues.reduce((sum, value) => sum + value.count, 0);
|
||||||
const environmentSummary = dashboardEnvironmentSummary(environment.snapshot);
|
const environmentSummary = dashboardEnvironmentSummary(environment.snapshot);
|
||||||
const typedObservationCount = environment.snapshot?.observations.length ?? 0;
|
const typedObservationCount = environment.snapshot?.observations.length ?? 0;
|
||||||
const directorySectionCount = environment.snapshot?.sections.length ?? 0;
|
const directorySectionCount = environment.snapshot?.sections.length ?? 0;
|
||||||
|
|
@ -667,17 +712,17 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="client-info-tile-grid">
|
<div className="client-info-tile-grid">
|
||||||
|
<div className="client-info-top-row">
|
||||||
<section className="client-info-metric-grid" aria-label="Client metrics">
|
<section className="client-info-metric-grid" aria-label="Client metrics">
|
||||||
<DashboardMetricTile label="Compliance Score" value={`${complianceScore}%`} />
|
<DashboardMetricTile label="Compliance Score" value={`${complianceScore}%`} emphasis />
|
||||||
<DashboardMetricTile label="Deviation" value={deviations.toLocaleString('en-US')} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
|
<DashboardMetricTile label="Deviation" value={deviations.toLocaleString('en-US')} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
|
||||||
<DashboardMetricTile label="Passed" value={passed.toLocaleString('en-US')} tone="passed" active={app.statusFilter === 'passed'} onClick={() => jump(() => setStatusFilter('passed'))} />
|
|
||||||
<DashboardMetricTile label="High Severity" value={highSeverity.toLocaleString('en-US')} tone="high" onClick={() => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: ['high'] }))} />
|
<DashboardMetricTile label="High Severity" value={highSeverity.toLocaleString('en-US')} tone="high" onClick={() => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: ['high'] }))} />
|
||||||
|
<DashboardMetricTile label="Passed" value={passed.toLocaleString('en-US')} tone="passed" />
|
||||||
<DashboardMetricTile label="Typed Observations" value={typedObservationCount.toLocaleString('en-US')} />
|
<DashboardMetricTile label="Typed Observations" value={typedObservationCount.toLocaleString('en-US')} />
|
||||||
<DashboardMetricTile label="Directory Sections" value={directorySectionCount.toLocaleString('en-US')} />
|
<DashboardMetricTile label="Directory Sections" value={directorySectionCount.toLocaleString('en-US')} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="client-info-card-grid">
|
<DashboardDonutCard title="Assessment Status" meta={`${total.toLocaleString('en-US')} records`}>
|
||||||
<DashboardLargeCard title="Assessment Status" meta={`${total.toLocaleString('en-US')} records`}>
|
|
||||||
<DashboardDonut total={total} centerLabel="records" segments={[
|
<DashboardDonut total={total} centerLabel="records" segments={[
|
||||||
{ label: 'Deviation', value: deviations, tone: 'deviation', onClick: () => jump(() => setStatusFilter('deviation')) },
|
{ label: 'Deviation', value: deviations, tone: 'deviation', onClick: () => jump(() => setStatusFilter('deviation')) },
|
||||||
{ label: 'Passed', value: passed, tone: 'passed', onClick: () => jump(() => setStatusFilter('passed')) },
|
{ label: 'Passed', value: passed, tone: 'passed', onClick: () => jump(() => setStatusFilter('passed')) },
|
||||||
|
|
@ -686,10 +731,63 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
{ label: 'Error', value: errors, tone: 'error', onClick: () => jump(() => setStatusFilter('error')) },
|
{ label: 'Error', value: errors, tone: 'error', onClick: () => jump(() => setStatusFilter('error')) },
|
||||||
{ label: 'IOEs found', value: explicitIoes, tone: 'info', onClick: explicitIoes > 0 ? () => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); }) : undefined }
|
{ label: 'IOEs found', value: explicitIoes, tone: 'info', onClick: explicitIoes > 0 ? () => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); }) : undefined }
|
||||||
]} />
|
]} />
|
||||||
|
</DashboardDonutCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-info-panels-layout">
|
||||||
|
<div className="client-info-card-grid">
|
||||||
|
<div className="client-info-card-column">
|
||||||
|
<DashboardLargeCard title="Directory Coverage" initiallyExpanded={false}
|
||||||
|
summary={{ label: 'observations', value: typedObservationCount }}
|
||||||
|
details={<p className="client-info-card-caption">{typedObservationCount.toLocaleString('en-US')} typed observations · {directorySectionCount.toLocaleString('en-US')} sections</p>}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.coverage} tone="coverage" />
|
||||||
</DashboardLargeCard>
|
</DashboardLargeCard>
|
||||||
|
|
||||||
<DashboardLargeCard title="Severity Distribution" meta={`${severityDistributionValues.reduce((sum, value) => sum + value.count, 0).toLocaleString('en-US')} classified`}>
|
<DashboardLargeCard title="Certificates & PKI" initiallyExpanded={false}
|
||||||
<DashboardDonut total={Math.max(1, severityDistributionValues.reduce((sum, value) => sum + value.count, 0))} centerLabel="classified" segments={severityDistributionValues.map(value => ({
|
summary={{ label: environmentSummary.certificates[0].label, value: environmentSummary.certificates[0].value, detail: environmentSummary.certificates[0].detail }}
|
||||||
|
details={<p className="client-info-card-caption">{environmentSummary.certificates.length.toLocaleString('en-US')} available certificate metrics</p>}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.certificates.slice(1)} tone="pki" />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Infrastructure Details" initiallyExpanded={false}>
|
||||||
|
<DashboardFactList items={[
|
||||||
|
{ label: 'Marked findings', value: activeSheet.analysis.records.filter(record => reviews[record.id]?.include).length.toLocaleString('en-US') },
|
||||||
|
...environmentSummary.resilienceFacts
|
||||||
|
]} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-info-card-column">
|
||||||
|
<DashboardLargeCard title="Identity & Privilege" initiallyExpanded={false}
|
||||||
|
summary={{ label: environmentSummary.identity[0].label, value: environmentSummary.identity[0].value, detail: environmentSummary.identity[0].detail }}
|
||||||
|
details={<p className="client-info-card-caption">{environmentSummary.identity.length.toLocaleString('en-US')} available identity metrics</p>}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.identity.slice(1)} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="GPO & Password Hygiene" initiallyExpanded={false}
|
||||||
|
summary={{ label: environmentSummary.gpo[0].label, value: environmentSummary.gpo[0].value, detail: environmentSummary.gpo[0].detail }}
|
||||||
|
details={<p className="client-info-card-caption">{environmentSummary.gpo.length.toLocaleString('en-US')} available policy metrics</p>}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.gpo.slice(1)} tone="gpo" />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-info-card-column">
|
||||||
|
<DashboardLargeCard title="Control Paths & Trusts" initiallyExpanded={false}
|
||||||
|
summary={{ label: environmentSummary.controlPaths[0].label, value: environmentSummary.controlPaths[0].value }}
|
||||||
|
details={<p className="client-info-card-caption">{environmentSummary.controlPaths.length.toLocaleString('en-US')} available control-path metrics</p>}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.controlPaths.slice(1)} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Infrastructure & Resilience" initiallyExpanded={false}
|
||||||
|
summary={{ label: environmentSummary.risk[0].label, value: environmentSummary.risk[0].value }}>
|
||||||
|
<DashboardMetricBars items={environmentSummary.risk.slice(1)} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="client-info-donut-rail" aria-label="Assessment distributions">
|
||||||
|
<DashboardDonutCard title="Severity Distribution" meta={`${classified.toLocaleString('en-US')} classified`} initiallyExpanded={false}>
|
||||||
|
<DashboardDonut total={Math.max(1, classified)} centerLabel="classified" segments={severityDistributionValues.map(value => ({
|
||||||
label: value.label,
|
label: value.label,
|
||||||
value: value.count,
|
value: value.count,
|
||||||
tone: value.value,
|
tone: value.value,
|
||||||
|
|
@ -699,37 +797,13 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
} else if (severityFacet) jump(() => setFacetFilter(severityFacet.id, { values: [value.value] }));
|
} else if (severityFacet) jump(() => setFacetFilter(severityFacet.id, { values: [value.value] }));
|
||||||
}
|
}
|
||||||
}))} />
|
}))} />
|
||||||
</DashboardLargeCard>
|
</DashboardDonutCard>
|
||||||
|
|
||||||
<DashboardLargeCard title="Directory Overview" meta={environment.failed ? 'Unavailable' : undefined}>
|
<DashboardLargeCard title="Directory Overview" meta={environment.failed ? 'Unavailable' : undefined} initiallyExpanded={false}
|
||||||
{!environment.snapshot && !environment.failed ? <p className="dashboard-empty">Loading scanner context…</p> : <DashboardFactList items={environmentSummary.directoryFacts} />}
|
summary={{ label: environmentSummary.directoryFacts[0].label, value: environmentSummary.directoryFacts[0].value }}>
|
||||||
</DashboardLargeCard>
|
{!environment.snapshot && !environment.failed ? <p className="dashboard-empty">Loading scanner context…</p> : <DashboardFactList items={environmentSummary.directoryFacts.slice(1)} />}
|
||||||
|
|
||||||
<DashboardLargeCard title="Directory Coverage" meta={`${typedObservationCount.toLocaleString('en-US')} observations`}>
|
|
||||||
<DashboardMetricBars items={environmentSummary.coverage} tone="coverage" />
|
|
||||||
<p className="client-info-card-caption">{typedObservationCount.toLocaleString('en-US')} typed observations · {directorySectionCount.toLocaleString('en-US')} sections</p>
|
|
||||||
</DashboardLargeCard>
|
|
||||||
|
|
||||||
<DashboardLargeCard title="Identity & Privilege">
|
|
||||||
<DashboardMetricBars items={environmentSummary.identity} />
|
|
||||||
</DashboardLargeCard>
|
|
||||||
|
|
||||||
<DashboardLargeCard title="Control Paths & Trusts">
|
|
||||||
<DashboardMetricBars items={environmentSummary.controlPaths} />
|
|
||||||
</DashboardLargeCard>
|
|
||||||
|
|
||||||
<DashboardLargeCard title="Certificates & PKI">
|
|
||||||
<DashboardMetricBars items={environmentSummary.certificates} tone="pki" />
|
|
||||||
</DashboardLargeCard>
|
|
||||||
|
|
||||||
<DashboardLargeCard title="GPO & Password Hygiene">
|
|
||||||
<DashboardMetricBars items={environmentSummary.gpo} tone="gpo" />
|
|
||||||
</DashboardLargeCard>
|
|
||||||
|
|
||||||
<DashboardLargeCard title="Infrastructure & Resilience" meta={`${activeSheet.analysis.records.filter(record => reviews[record.id]?.include).length.toLocaleString('en-US')} marked`} className="client-info-resilience">
|
|
||||||
<DashboardMetricBars items={environmentSummary.risk} tone="risk" />
|
|
||||||
<DashboardFactList items={environmentSummary.resilienceFacts} />
|
|
||||||
</DashboardLargeCard>
|
</DashboardLargeCard>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,16 @@ import { recordTitleFor } from './record-title';
|
||||||
*/
|
*/
|
||||||
const RECORD_ROW_HEIGHT = 78;
|
const RECORD_ROW_HEIGHT = 78;
|
||||||
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
|
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
|
||||||
const TILE_MIN_WIDTH = 320;
|
const TILE_MIN_WIDTH = 358;
|
||||||
|
/** Verbindliche Mindesthoehe der regulaeren Finding-Kachel. */
|
||||||
|
const TILE_MIN_HEIGHT = 223;
|
||||||
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
|
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
|
||||||
const TILE_GAP = 12;
|
const TILE_GAP = 12;
|
||||||
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
|
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
|
||||||
const TILE_GRID_INSET = 20;
|
const TILE_GRID_INSET = 20;
|
||||||
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
|
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
|
||||||
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
||||||
const TILE_ROW_HEIGHT = 172;
|
const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
|
||||||
|
|
||||||
const resultColors = [
|
const resultColors = [
|
||||||
'#7c3aed', // Violet
|
'#7c3aed', // Violet
|
||||||
|
|
@ -317,7 +319,7 @@ export function DatasetViewer() {
|
||||||
// literalen Inline-Style-Objekts, damit die Darstellung sonst in
|
// literalen Inline-Style-Objekts, damit die Darstellung sonst in
|
||||||
// src/styles bleibt (siehe FloatingResultsWindow.tsx fuer denselben
|
// src/styles bleibt (siehe FloatingResultsWindow.tsx fuer denselben
|
||||||
// Kompromiss bei einer anderen laufzeitberechneten Position).
|
// Kompromiss bei einer anderen laufzeitberechneten Position).
|
||||||
const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))` };
|
const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, var(--tile-content-width))` };
|
||||||
const tileTopSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingTop };
|
const tileTopSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingTop };
|
||||||
const tileBottomSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingBottom };
|
const tileBottomSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingBottom };
|
||||||
const recordTopSpacerStyle = { height: recordWindow.paddingTop };
|
const recordTopSpacerStyle = { height: recordWindow.paddingTop };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,30 @@ import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
|
||||||
import { ResizableResultTable } from '../../components/ResizableResultTable/ResizableResultTable';
|
import { ResizableResultTable } from '../../components/ResizableResultTable/ResizableResultTable';
|
||||||
import type { ResultSection } from './result-message';
|
import type { ResultSection } from './result-message';
|
||||||
import type { ResultSheetRowTone } from './result-message';
|
import type { ResultSheetRowTone } from './result-message';
|
||||||
|
import type { MaesterExecutionError } from './maester-error';
|
||||||
|
|
||||||
|
function MaesterErrorResult({ error, search }: { error: MaesterExecutionError; search: string }) {
|
||||||
|
const facts = [
|
||||||
|
{ label: 'Error code', value: error.code },
|
||||||
|
{ label: 'HTTP status', value: error.httpStatus },
|
||||||
|
{ label: 'Request', value: [error.method, error.endpoint].filter(Boolean).join(' ') || undefined },
|
||||||
|
{ label: 'Source', value: error.source },
|
||||||
|
{ label: 'Timestamp', value: error.timestamp },
|
||||||
|
{ label: 'Request ID', value: error.requestId },
|
||||||
|
{ label: 'Client request ID', value: error.clientRequestId }
|
||||||
|
].filter((fact): fact is { label: string; value: string } => Boolean(fact.value));
|
||||||
|
|
||||||
|
return <div className="maester-error">
|
||||||
|
<p className="maester-error__summary">{highlighted(error.message, search)}</p>
|
||||||
|
{facts.length > 0 && <dl className="maester-error__facts">{facts.map(fact => <div key={fact.label}>
|
||||||
|
<dt>{fact.label}</dt><dd>{highlighted(fact.value, search)}</dd>
|
||||||
|
</div>)}</dl>}
|
||||||
|
<details className="maester-error__raw">
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<pre>{highlighted(error.raw, search)}</pre>
|
||||||
|
</details>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function highlighted(value: string, search: string): ReactNode {
|
function highlighted(value: string, search: string): ReactNode {
|
||||||
if (!search) return value;
|
if (!search) return value;
|
||||||
|
|
@ -53,7 +77,9 @@ export function LinkedResults({ sheets, search = '', heading, sections = [], sho
|
||||||
{sections.map(section => (
|
{sections.map(section => (
|
||||||
<section className="result-block" key={section.title}>
|
<section className="result-block" key={section.title}>
|
||||||
<h4 className="result-block__title">{section.title}</h4>
|
<h4 className="result-block__title">{section.title}</h4>
|
||||||
<RichTextValue value={section.value} search={search} resizableTables={resizableTables} />
|
{section.diagnostic
|
||||||
|
? <MaesterErrorResult error={section.diagnostic} search={search} />
|
||||||
|
: <RichTextValue value={section.value} search={search} resizableTables={resizableTables} />}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
{sheets.map((sheet, index) => {
|
{sheets.map((sheet, index) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
export interface MaesterExecutionError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
method?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
httpStatus?: string;
|
||||||
|
source?: string;
|
||||||
|
requestId?: string;
|
||||||
|
clientRequestId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function embeddedError(value: string): Record<string, unknown> | undefined {
|
||||||
|
const start = value.search(/\{\s*"error"\s*:/i);
|
||||||
|
if (start < 0) return undefined;
|
||||||
|
let depth = 0;
|
||||||
|
let quoted = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = start; index < value.length; index += 1) {
|
||||||
|
const character = value[index];
|
||||||
|
if (quoted) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (character === '\\') escaped = true;
|
||||||
|
else if (character === '"') quoted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"') quoted = true;
|
||||||
|
else if (character === '{') depth += 1;
|
||||||
|
else if (character === '}' && --depth === 0) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value.slice(start, index + 1)) as { error?: Record<string, unknown> };
|
||||||
|
return parsed.error;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMaesterExecutionError(value: string): MaesterExecutionError | undefined {
|
||||||
|
const raw = value.trim().replace(/^```\w*\s*/, '').replace(/\s*```$/, '').trim();
|
||||||
|
if (!/an error occurred while running the test/i.test(raw) || !/Invoke-MgGraphRequest/i.test(raw)) return undefined;
|
||||||
|
|
||||||
|
const request = /\b(GET|POST|PUT|PATCH|DELETE)\s+(https:\/\/[^\s|]+)\s+HTTP\/\d(?:\.\d)?\s+(\d{3})\s+(.+?)(?=\s+(?:Cache-Control|Vary|Strict-Transport-Security|request-id|Content-Type)\b|\s*\||\s*\{)/i.exec(raw);
|
||||||
|
const location = /Invoke-MgGraphRequest:\s+(.+?\.ps1):(\d+)/i.exec(raw);
|
||||||
|
const error = embeddedError(raw);
|
||||||
|
const inner = error && typeof error.innerError === 'object' && error.innerError !== null
|
||||||
|
? error.innerError as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: text(error?.message) ?? 'An error occurred while running the Maester test.',
|
||||||
|
code: text(error?.code),
|
||||||
|
method: request?.[1]?.toUpperCase(),
|
||||||
|
endpoint: request?.[2],
|
||||||
|
httpStatus: request ? `${request[3]} ${request[4].trim()}` : undefined,
|
||||||
|
source: location ? `${location[1].split(/[\\/]/).pop()} · line ${location[2]}` : undefined,
|
||||||
|
requestId: text(inner?.['request-id']) ?? text(error?.['request-id']),
|
||||||
|
clientRequestId: text(inner?.['client-request-id']) ?? text(error?.['client-request-id']),
|
||||||
|
timestamp: text(inner?.date) ?? text(error?.date),
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,13 @@ import type { FindingRecord, RecordField } from '../../domain/finding';
|
||||||
import type { DatasetSheet } from '../../domain/dataset';
|
import type { DatasetSheet } from '../../domain/dataset';
|
||||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||||
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
||||||
|
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
|
||||||
|
|
||||||
/** Ein benannter Ergebnisabschnitt des Results-Fensters. */
|
/** Ein benannter Ergebnisabschnitt des Results-Fensters. */
|
||||||
export interface ResultSection {
|
export interface ResultSection {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
diagnostic?: MaesterExecutionError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -173,9 +175,15 @@ export function resultSectionFields(record: FindingRecord, fallbackToolId?: stri
|
||||||
* in Feldreihenfolge.
|
* in Feldreihenfolge.
|
||||||
*/
|
*/
|
||||||
export function resultSectionsFor(record: FindingRecord, fallbackToolId?: string): ResultSection[] {
|
export function resultSectionsFor(record: FindingRecord, fallbackToolId?: string): ResultSection[] {
|
||||||
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
||||||
const message = resultMessageFor(record, fallbackToolId);
|
const message = resultMessageFor(record, fallbackToolId);
|
||||||
|
const diagnostic = toolId === 'maester' && message ? parseMaesterExecutionError(message) : undefined;
|
||||||
return [
|
return [
|
||||||
...(message ? [{ title: TOOL_MESSAGE_TITLE, value: message }] : []),
|
...(message ? [{
|
||||||
|
title: TOOL_MESSAGE_TITLE,
|
||||||
|
value: message,
|
||||||
|
...(diagnostic ? { diagnostic } : {})
|
||||||
|
}] : []),
|
||||||
...resultSectionFields(record, fallbackToolId).map(field => ({ title: field.label, value: field.value }))
|
...resultSectionFields(record, fallbackToolId).map(field => ({ title: field.label, value: field.value }))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { FileImport } from '../file-import/FileImport';
|
||||||
import { stableAssessmentId, stableProjectId } from '../../shared/identifiers';
|
import { stableAssessmentId, stableProjectId } from '../../shared/identifiers';
|
||||||
import { groupProjectImportFiles, type ScanImportGroup } from './datahub-import-groups';
|
import { groupProjectImportFiles, type ScanImportGroup } from './datahub-import-groups';
|
||||||
import { GpoFolderImport } from './GpoFolderImport';
|
import { GpoFolderImport } from './GpoFolderImport';
|
||||||
|
import { PolicyAnalysisPanel } from './PolicyAnalysisPanel';
|
||||||
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
|
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
|
||||||
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
|
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
|
||||||
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
|
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
|
||||||
|
|
@ -40,6 +41,16 @@ interface SaveFeedback {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
type ScanReadinessState = DatahubScanReadiness | 'checking' | 'unavailable';
|
type ScanReadinessState = DatahubScanReadiness | 'checking' | 'unavailable';
|
||||||
|
type TenantDetailSection = 'client-information' | 'gpo-analysis';
|
||||||
|
|
||||||
|
const gpoStateLabels: Record<GpoBackupStatusRecord['state'], string> = {
|
||||||
|
queued: 'Queued',
|
||||||
|
running: 'Processing',
|
||||||
|
succeeded: 'Ready',
|
||||||
|
partial: 'Partial',
|
||||||
|
'dead-letter': 'Failed',
|
||||||
|
'recovery-required': 'Recovery pending'
|
||||||
|
};
|
||||||
|
|
||||||
function projectConsultant(project: AssessmentProject): string {
|
function projectConsultant(project: AssessmentProject): string {
|
||||||
return typeof project.attributes.consultant === 'string' ? project.attributes.consultant : '';
|
return typeof project.attributes.consultant === 'string' ? project.attributes.consultant : '';
|
||||||
|
|
@ -107,6 +118,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
|
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
|
||||||
const [timeslotDate, setTimeslotDate] = useState('');
|
const [timeslotDate, setTimeslotDate] = useState('');
|
||||||
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
|
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
|
||||||
|
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
|
||||||
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
|
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -178,7 +190,11 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => { void loadProjects(); };
|
const refresh = () => { void loadProjects(); };
|
||||||
globalThis.addEventListener('assessor:datahub-changed', refresh);
|
globalThis.addEventListener('assessor:datahub-changed', refresh);
|
||||||
return () => globalThis.removeEventListener('assessor:datahub-changed', refresh);
|
globalThis.addEventListener('assessor:gpo-changed', refresh);
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener('assessor:datahub-changed', refresh);
|
||||||
|
globalThis.removeEventListener('assessor:gpo-changed', refresh);
|
||||||
|
};
|
||||||
}, [loadProjects]);
|
}, [loadProjects]);
|
||||||
|
|
||||||
const groupsForProject = useCallback((projectId: string): ScanImportGroup[] => {
|
const groupsForProject = useCallback((projectId: string): ScanImportGroup[] => {
|
||||||
|
|
@ -289,6 +305,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
setMasterDataEditing(true);
|
setMasterDataEditing(true);
|
||||||
setExcludedScanRunIds([]);
|
setExcludedScanRunIds([]);
|
||||||
setExpandedTimeslotIds([]);
|
setExpandedTimeslotIds([]);
|
||||||
|
setDetailSection('client-information');
|
||||||
setForm({ mode: 'create', name: '', description: '', consultant: '' });
|
setForm({ mode: 'create', name: '', description: '', consultant: '' });
|
||||||
};
|
};
|
||||||
const openEdit = (project: AssessmentProject) => {
|
const openEdit = (project: AssessmentProject) => {
|
||||||
|
|
@ -306,6 +323,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
setExpandedTimeslotIds(affectedTimeslotIds);
|
setExpandedTimeslotIds(affectedTimeslotIds);
|
||||||
setTimeslotDialogOpen(false);
|
setTimeslotDialogOpen(false);
|
||||||
setTimeslotDeleteTarget(null);
|
setTimeslotDeleteTarget(null);
|
||||||
|
setDetailSection('client-information');
|
||||||
setForm({ mode: 'edit', project, name: project.name, description: project.description ?? '', consultant: projectConsultant(project) });
|
setForm({ mode: 'edit', project, name: project.name, description: project.description ?? '', consultant: projectConsultant(project) });
|
||||||
};
|
};
|
||||||
const updateForm = (patch: Partial<ProjectFormState>) => {
|
const updateForm = (patch: Partial<ProjectFormState>) => {
|
||||||
|
|
@ -509,6 +527,38 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const retryGpoBackup = async (record: GpoBackupStatusRecord) => {
|
||||||
|
const projectId = form?.project?.id;
|
||||||
|
if (!projectId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSaveFeedback(null);
|
||||||
|
try {
|
||||||
|
await assessorApi.retryGpoBackup(projectId, record.artifactId);
|
||||||
|
await loadProjects();
|
||||||
|
setSaveFeedback({ tone: 'success', message: 'The GPO analysis job was requeued.' });
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'The GPO analysis job could not be restarted.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteFailedGpoBackup = async (record: GpoBackupStatusRecord) => {
|
||||||
|
const projectId = form?.project?.id;
|
||||||
|
if (!projectId || !globalThis.confirm(`Permanently delete the failed GPO import "${record.fileName}" from ASSESSOR? The source remains unchanged.`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSaveFeedback(null);
|
||||||
|
try {
|
||||||
|
await assessorApi.deleteFailedGpoBackup(projectId, record.artifactId);
|
||||||
|
await loadProjects();
|
||||||
|
setSaveFeedback({ tone: 'success', message: 'The failed GPO import was deleted from ASSESSOR.' });
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'The failed GPO import could not be deleted.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
if (backendStatus !== 'connected') {
|
if (backendStatus !== 'connected') {
|
||||||
return <div className="datahub-page">
|
return <div className="datahub-page">
|
||||||
{/* Kein Eyebrow, kein Inhaltstitel: den Seitennamen traegt die Topbar
|
{/* Kein Eyebrow, kein Inhaltstitel: den Seitennamen traegt die Topbar
|
||||||
|
|
@ -567,10 +617,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
|
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
|
||||||
<strong>{formatTenantDate(group.fileDate)}</strong>
|
<strong>{formatTenantDate(group.fileDate)}</strong>
|
||||||
</label>
|
</label>
|
||||||
<span className="tenant-history-meta"><b>{reports.length.toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
|
<span className="tenant-history-meta"><b>{(reports.length + group.gpoBackups.length).toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
|
||||||
</summary>
|
</summary>
|
||||||
{reports.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No scan reports</h3><p>This timeslot does not contain a scan report yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Scan reports from ${formatTenantDate(group.fileDate)}`}>
|
{reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}>
|
||||||
<div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Scan</span><span>Imported</span><span>Status</span><span>Actions</span></div>
|
<div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div>
|
||||||
{reports.map(report => {
|
{reports.map(report => {
|
||||||
const readiness = scanReadiness[report.scanRunId] ?? 'checking';
|
const readiness = scanReadiness[report.scanRunId] ?? 'checking';
|
||||||
const processing = processingByRun.get(report.scanRunId)!;
|
const processing = processingByRun.get(report.scanRunId)!;
|
||||||
|
|
@ -596,6 +646,16 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
<button className="btn tenant-scan-delete" type="button" title={`Delete ${report.tool.name} scan report`} aria-label={`Delete ${report.tool.name} scan report`} onClick={() => setScanDeleteTarget(report)}>Delete</button>
|
<button className="btn tenant-scan-delete" type="button" title={`Delete ${report.tool.name} scan report`} aria-label={`Delete ${report.tool.name} scan report`} onClick={() => setScanDeleteTarget(report)}>Delete</button>
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
|
{group.gpoBackups.map(record => <div className="tenant-report-row tenant-report-gpo" role="row" key={record.artifactId}>
|
||||||
|
<span className="tenant-gpo-source-marker" aria-label="Policy analysis source">G</span>
|
||||||
|
<span className="tenant-report-copy"><b>GPO Backup</b><small>{record.fileName} · {record.gpoCount.toLocaleString('de-DE')} GPO{record.gpoCount === 1 ? '' : 's'}</small></span>
|
||||||
|
<span className="tenant-report-imported">{formatHistoryDate(record.submittedAt)}</span>
|
||||||
|
<b className="tenant-report-status" data-status={record.state}>{gpoStateLabels[record.state]}</b>
|
||||||
|
{record.state === 'dead-letter' ? <span className="tenant-gpo-row-actions">
|
||||||
|
<button className="btn compact" type="button" disabled={busy} onClick={() => void retryGpoBackup(record)}>Retry</button>
|
||||||
|
<button className="btn compact danger" type="button" disabled={busy} onClick={() => void deleteFailedGpoBackup(record)}>Delete</button>
|
||||||
|
</span> : <button className="btn tenant-gpo-view" type="button" onClick={() => setDetailSection('gpo-analysis')}>View</button>}
|
||||||
|
</div>)}
|
||||||
</div>}
|
</div>}
|
||||||
{affectedReports.length > 0 && <section className="tenant-processing-alert" data-status={hasProcessingError ? 'error' : 'update'} aria-label={hasProcessingError ? 'ASSESSOR processing errors require attention' : 'ASSESSOR processing profile updates available'}>
|
{affectedReports.length > 0 && <section className="tenant-processing-alert" data-status={hasProcessingError ? 'error' : 'update'} aria-label={hasProcessingError ? 'ASSESSOR processing errors require attention' : 'ASSESSOR processing profile updates available'}>
|
||||||
<header><strong>{hasProcessingError ? 'ASSESSOR processing issue' : 'New ASSESSOR processing profile available'}</strong><span>{hasProcessingError
|
<header><strong>{hasProcessingError ? 'ASSESSOR processing issue' : 'New ASSESSOR processing profile available'}</strong><span>{hasProcessingError
|
||||||
|
|
@ -621,7 +681,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
{selected && <div className="tenant-timeslot-actions">
|
{selected && <div className="tenant-timeslot-actions">
|
||||||
<button className="btn" type="button" onClick={() => setTimeslotDeleteTarget(group)}>Delete timeslot</button>
|
<button className="btn" type="button" onClick={() => setTimeslotDeleteTarget(group)}>Delete timeslot</button>
|
||||||
<FileImport targetProjectId={editedProject.id} targetAssessmentId={group.assessmentIds[0]} label="Import Scan"/>
|
<FileImport targetProjectId={editedProject.id} targetAssessmentId={group.assessmentIds[0]} label="Import Scan"/>
|
||||||
<GpoFolderImport projectId={editedProject.id} assessmentId={group.assessmentIds[0]} label="Import GPO Backup Folder (Zip)" showStatus={false}/>
|
<GpoFolderImport projectId={editedProject.id} assessmentId={group.assessmentIds[0]} label="Import GPO Folder" archiveLabel="Import GPO ZIP" showStatus={false}/>
|
||||||
</div>}
|
</div>}
|
||||||
</details>;
|
</details>;
|
||||||
})}
|
})}
|
||||||
|
|
@ -630,17 +690,17 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
{form ? <div className="tenant-detail-layout">
|
{form ? <div className="tenant-detail-layout">
|
||||||
<aside className="tenant-detail-navigation">
|
<aside className="tenant-detail-navigation">
|
||||||
<section className="tenant-editor-header">
|
<section className="tenant-editor-header">
|
||||||
<button className="tenant-back" type="button" title="Back to client overview" aria-label="Back to client overview" onClick={() => { if (!busy) { setMasterDataEditing(false); setExcludedScanRunIds([]); setForm(null); } }}><TriangleIcon direction="left" /><span>Back</span></button>
|
<button className="tenant-back" type="button" title="Back to client overview" aria-label="Back to client overview" onClick={() => { if (!busy) { setMasterDataEditing(false); setExcludedScanRunIds([]); setDetailSection('client-information'); setForm(null); } }}><TriangleIcon direction="left" /><span>Back</span></button>
|
||||||
{form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>}
|
{form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>}
|
||||||
<div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div>
|
<div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div>
|
||||||
</section>
|
</section>
|
||||||
{form.mode === 'edit' && filesSection}
|
{form.mode === 'edit' && filesSection}
|
||||||
</aside>
|
</aside>
|
||||||
<main className="tenant-detail-information">
|
<main className="tenant-detail-information">
|
||||||
<section className="tenant-information-sheet" aria-label="Client information sheet">
|
{detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet">
|
||||||
<header className="tenant-information-header">
|
<header className="tenant-information-header">
|
||||||
<div className="tenant-information-title"><strong>Client Information</strong></div>
|
<div className="tenant-information-title"><strong>Client Information</strong></div>
|
||||||
<div className="tenant-information-actions">{form.project && !masterDataEditing && <button className="btn danger tenant-delete-client" type="button" onClick={() => setDeleteTarget(form.project!)}>Delete client</button>}{form.mode === 'edit' && !masterDataEditing && <button className="btn primary tenant-edit-client" type="button" disabled={busy} onClick={() => { setSaveFeedback(null); setMasterDataEditing(true); }}>Edit</button>}{(form.mode === 'create' || masterDataEditing) && <button className="btn primary tenant-save-client" type="submit" form="tenant-editor-form" disabled={busy || !form.name.trim()}>{busy ? 'Saving …' : 'Save'}</button>}</div>
|
<div className="tenant-information-actions">{form.project && projectGpoData[form.project.id]?.length > 0 && !masterDataEditing && <button className="btn tenant-show-gpo" type="button" onClick={() => setDetailSection('gpo-analysis')}>GPO Analysis</button>}{form.project && !masterDataEditing && <button className="btn danger tenant-delete-client" type="button" onClick={() => setDeleteTarget(form.project!)}>Delete client</button>}{form.mode === 'edit' && !masterDataEditing && <button className="btn primary tenant-edit-client" type="button" disabled={busy} onClick={() => { setSaveFeedback(null); setMasterDataEditing(true); }}>Edit</button>}{(form.mode === 'create' || masterDataEditing) && <button className="btn primary tenant-save-client" type="submit" form="tenant-editor-form" disabled={busy || !form.name.trim()}>{busy ? 'Saving …' : 'Save'}</button>}</div>
|
||||||
</header>
|
</header>
|
||||||
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => setError(null)}>Close</button></div>}
|
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => setError(null)}>Close</button></div>}
|
||||||
{saveFeedback && <div className={"tenant-save-feedback " + saveFeedback.tone} role={saveFeedback.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{saveFeedback.tone === "success" ? "✓" : "!"}</span><strong>{saveFeedback.message}</strong></div>}
|
{saveFeedback && <div className={"tenant-save-feedback " + saveFeedback.tone} role={saveFeedback.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{saveFeedback.tone === "success" ? "✓" : "!"}</span><strong>{saveFeedback.message}</strong></div>}
|
||||||
|
|
@ -649,7 +709,13 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
<label><span className="tenant-field-label">Consultant{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <input className="input" value={form.consultant} onChange={event => updateForm({ consultant: event.target.value })} placeholder="Responsible person"/> : <span className="tenant-field-value">{form.consultant || '—'}</span>}</label>
|
<label><span className="tenant-field-label">Consultant{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <input className="input" value={form.consultant} onChange={event => updateForm({ consultant: event.target.value })} placeholder="Responsible person"/> : <span className="tenant-field-value">{form.consultant || '—'}</span>}</label>
|
||||||
<label className="tenant-editor-description"><span className="tenant-field-label">Remarks{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <textarea className="input" value={form.description} onChange={event => updateForm({ description: event.target.value })} rows={10} placeholder="Notes about the client or project"/> : <span className="tenant-field-value tenant-field-prose">{form.description || '—'}</span>}</label>
|
<label className="tenant-editor-description"><span className="tenant-field-label">Remarks{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <textarea className="input" value={form.description} onChange={event => updateForm({ description: event.target.value })} rows={10} placeholder="Notes about the client or project"/> : <span className="tenant-field-value tenant-field-prose">{form.description || '—'}</span>}</label>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section> : form.project ? <section className="tenant-information-sheet tenant-policy-information" aria-label="GPO policy analysis">
|
||||||
|
<header className="tenant-information-header">
|
||||||
|
<div className="tenant-information-title"><strong>GPO Analysis</strong></div>
|
||||||
|
<div className="tenant-information-actions"><button className="btn" type="button" onClick={() => setDetailSection('client-information')}>Client Information</button></div>
|
||||||
|
</header>
|
||||||
|
<PolicyAnalysisPanel projectId={form.project.id}/>
|
||||||
|
</section> : null}
|
||||||
</main>
|
</main>
|
||||||
</div> : <>
|
</div> : <>
|
||||||
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void loadProjects()}>Neu laden</button></div>}
|
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void loadProjects()}>Neu laden</button></div>}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface GpoFolderImportProps {
|
||||||
assessmentId?: string;
|
assessmentId?: string;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
archiveLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Feedback {
|
interface Feedback {
|
||||||
|
|
@ -43,8 +44,15 @@ function formatTimestamp(value: string): string {
|
||||||
return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
|
return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GpoFolderImport({ projectId, assessmentId, showStatus = true, label = 'GPO Backup Folder' }: GpoFolderImportProps) {
|
export function GpoFolderImport({
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
projectId,
|
||||||
|
assessmentId,
|
||||||
|
showStatus = true,
|
||||||
|
label = 'Import GPO Folder',
|
||||||
|
archiveLabel = 'Import GPO ZIP'
|
||||||
|
}: GpoFolderImportProps) {
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const archiveInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||||
const [records, setRecords] = useState<GpoBackupStatusRecord[]>([]);
|
const [records, setRecords] = useState<GpoBackupStatusRecord[]>([]);
|
||||||
|
|
@ -62,7 +70,7 @@ export function GpoFolderImport({ projectId, assessmentId, showStatus = true, la
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = inputRef.current;
|
const input = folderInputRef.current;
|
||||||
if (input) {
|
if (input) {
|
||||||
input.setAttribute('webkitdirectory', '');
|
input.setAttribute('webkitdirectory', '');
|
||||||
input.setAttribute('directory', '');
|
input.setAttribute('directory', '');
|
||||||
|
|
@ -115,7 +123,14 @@ export function GpoFolderImport({ projectId, assessmentId, showStatus = true, la
|
||||||
setManagedArtifactId(undefined);
|
setManagedArtifactId(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const submit = async (fileList: FileList) => {
|
const announceSubmission = async (file: File, successMessage: (deduplicated: boolean) => string) => {
|
||||||
|
const result = await assessorApi.submitGpoBackup(projectId, file, assessmentId);
|
||||||
|
setFeedback({ tone: 'success', message: successMessage(result.deduplicated) });
|
||||||
|
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId, jobId: result.jobId } }));
|
||||||
|
await loadStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitFolder = async (fileList: FileList) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -129,15 +144,9 @@ export function GpoFolderImport({ projectId, assessmentId, showStatus = true, la
|
||||||
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
|
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
|
||||||
envelopeBytes.set(transport.bytes);
|
envelopeBytes.set(transport.bytes);
|
||||||
const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' });
|
const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' });
|
||||||
const result = await assessorApi.submitGpoBackup(projectId, envelope, assessmentId);
|
await announceSubmission(envelope, deduplicated => deduplicated
|
||||||
setFeedback({
|
|
||||||
tone: 'success',
|
|
||||||
message: result.deduplicated
|
|
||||||
? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files).`
|
? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files).`
|
||||||
: `GPO backup folder accepted · analysis job queued (${transport.fileCount} files).`
|
: `GPO backup folder accepted · analysis job queued (${transport.fileCount} files).`);
|
||||||
});
|
|
||||||
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId, jobId: result.jobId } }));
|
|
||||||
await loadStatus();
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' });
|
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -145,21 +154,51 @@ export function GpoFolderImport({ projectId, assessmentId, showStatus = true, la
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitArchive = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
if (!file.name.toLocaleLowerCase('en-US').endsWith('.zip')) {
|
||||||
|
throw new Error('Please select a ZIP file containing a GPO backup.');
|
||||||
|
}
|
||||||
|
await announceSubmission(file, deduplicated => deduplicated
|
||||||
|
? 'GPO backup ZIP already exists · analysis job confirmed.'
|
||||||
|
: 'GPO backup ZIP accepted · analysis job queued.');
|
||||||
|
} catch (reason) {
|
||||||
|
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup ZIP could not be imported.' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<span className="gpo-folder-actions">
|
<span className="gpo-folder-actions">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={folderInputRef}
|
||||||
className="hidden-input"
|
className="hidden-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
if (event.target.files?.length) void submit(event.target.files);
|
if (event.target.files?.length) void submitFolder(event.target.files);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn primary" type="button" disabled={busy} onClick={() => inputRef.current?.click()}>
|
<input
|
||||||
|
ref={archiveInputRef}
|
||||||
|
className="hidden-input"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
onChange={event => {
|
||||||
|
if (event.target.files?.[0]) void submitArchive(event.target.files[0]);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn primary" type="button" disabled={busy} onClick={() => folderInputRef.current?.click()}>
|
||||||
{busy ? 'Checking GPO …' : label}
|
{busy ? 'Checking GPO …' : label}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn primary" type="button" disabled={busy} onClick={() => archiveInputRef.current?.click()}>
|
||||||
|
{busy ? 'Checking GPO …' : archiveLabel}
|
||||||
|
</button>
|
||||||
{feedback && <span className={`gpo-folder-feedback ${feedback.tone}`} role={feedback.tone === 'error' ? 'alert' : 'status'}>
|
{feedback && <span className={`gpo-folder-feedback ${feedback.tone}`} role={feedback.tone === 'error' ? 'alert' : 'status'}>
|
||||||
{feedback.message}
|
{feedback.message}
|
||||||
</span>}
|
</span>}
|
||||||
|
|
|
||||||
|
|
@ -39,34 +39,35 @@ function ReviewRow({ item }: { item: PolicyAnalysisReviewItem }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysisOverview }) {
|
export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysisOverview }) {
|
||||||
const visibleReviews = overview.reviewItems.slice(0, 100);
|
const visibleReviews = overview.current.reviewItems.slice(0, 100);
|
||||||
|
const currentSummary = overview.current.summary;
|
||||||
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
|
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
|
||||||
const hasPipelineData = overview.summary.identityAnalysisRuns > 0 || overview.summary.aggregationRuns > 0
|
const hasPipelineData = overview.summary.identityAnalysisRuns > 0 || overview.summary.aggregationRuns > 0
|
||||||
|| overview.summary.evaluationRuns > 0 || overview.summary.findingRuns > 0;
|
|| overview.summary.evaluationRuns > 0 || overview.summary.findingRuns > 0;
|
||||||
return <section className="policy-analysis-panel" aria-label="Policy analysis results">
|
return <section className="policy-analysis-panel" aria-label="Policy analysis results">
|
||||||
<div className="datahub-section-heading">
|
<div className="datahub-section-heading">
|
||||||
<div><span>Policy Analysis</span><h3>GPO Baseline Evaluation</h3></div>
|
<div><span>Policy Analysis</span><h3>GPO Baseline Evaluation</h3></div>
|
||||||
<strong>{overview.summary.evaluationRuns.toLocaleString('en-US')}</strong>
|
<strong>{currentSummary.evaluationRuns.toLocaleString('en-US')}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p className="policy-analysis-boundary" role="note">
|
<p className="policy-analysis-boundary" role="note">
|
||||||
Backup configuration evidence only. Effective application is not verified without RSoP or GP Results.
|
Backup configuration evidence only. Effective application is not verified without RSoP or GP Results.
|
||||||
Counts summarize persisted run history; every evaluation remains a separate auditable record.
|
Summary and review counts represent the current active snapshot chains. Evaluation history remains auditable below.
|
||||||
</p>
|
</p>
|
||||||
{!hasPipelineData ? <div className="datahub-empty policy-analysis-empty">
|
{!hasPipelineData ? <div className="datahub-empty policy-analysis-empty">
|
||||||
<h3>No policy analysis runs yet</h3>
|
<h3>No policy analysis runs yet</h3>
|
||||||
<p>Imported GPO backups remain processing evidence until snapshot aggregation and baseline evaluation have run.</p>
|
<p>Imported GPO backups remain processing evidence until snapshot aggregation and baseline evaluation have run.</p>
|
||||||
</div> : <>
|
</div> : <>
|
||||||
<div className="policy-analysis-summary" aria-label="Policy analysis summary">
|
<div className="policy-analysis-summary" aria-label="Policy analysis summary">
|
||||||
<article><span>Confirmed policy findings</span><strong>{overview.summary.policyFindings}</strong></article>
|
<article><span>Confirmed policy findings</span><strong>{currentSummary.policyFindings}</strong></article>
|
||||||
<article><span>Review records</span><strong>{overview.summary.reviewItems}</strong></article>
|
<article><span>Review records</span><strong>{currentSummary.reviewItems}</strong></article>
|
||||||
<article><span>Conflicts</span><strong>{overview.summary.conflicts}</strong></article>
|
<article><span>Conflicts</span><strong>{currentSummary.conflicts}</strong></article>
|
||||||
<article><span>Partial runs</span><strong>{overview.summary.partialRuns}</strong></article>
|
<article><span>Partial runs</span><strong>{currentSummary.partialRuns}</strong></article>
|
||||||
<article><span>Catalog / parser gaps</span><strong>{overview.summary.catalogGaps + overview.summary.parserGaps}</strong></article>
|
<article><span>Catalog / parser gaps</span><strong>{currentSummary.catalogGaps + currentSummary.parserGaps}</strong></article>
|
||||||
<article><span>Unresolved identities</span><strong>{overview.summary.unresolvedIdentities}</strong></article>
|
<article><span>Unresolved identities</span><strong>{currentSummary.unresolvedIdentities}</strong></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="policy-analysis-columns">
|
<div className="policy-analysis-columns">
|
||||||
<section>
|
<section>
|
||||||
<div className="policy-analysis-subheading"><h4>Evaluation runs</h4><span>{overview.evaluations.length}</span></div>
|
<div className="policy-analysis-subheading"><h4>Evaluation history</h4><span>{overview.evaluations.length}</span></div>
|
||||||
{!overview.evaluations.length ? <p className="policy-analysis-muted">No baseline evaluation has been persisted yet.</p>
|
{!overview.evaluations.length ? <p className="policy-analysis-muted">No baseline evaluation has been persisted yet.</p>
|
||||||
: <div className="policy-evaluation-list">{overview.evaluations.map(run => <article key={run.evaluationRunId}>
|
: <div className="policy-evaluation-list">{overview.evaluations.map(run => <article key={run.evaluationRunId}>
|
||||||
<header><strong>{profileById.get(run.environmentProfileId)?.name ?? 'Environment profile'} · {run.status === 'complete' ? 'complete' : run.status}</strong><span>{formatTimestamp(run.completedAt)}</span></header>
|
<header><strong>{profileById.get(run.environmentProfileId)?.name ?? 'Environment profile'} · {run.status === 'complete' ? 'complete' : run.status}</strong><span>{formatTimestamp(run.completedAt)}</span></header>
|
||||||
|
|
@ -75,10 +76,10 @@ export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysis
|
||||||
</article>)}</div>}
|
</article>)}</div>}
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div className="policy-analysis-subheading"><h4>Review queue</h4><span>{overview.reviewItems.length}</span></div>
|
<div className="policy-analysis-subheading"><h4>Current review queue</h4><span>{overview.current.reviewItems.length}</span></div>
|
||||||
{!visibleReviews.length ? <p className="policy-analysis-muted">No conflicts or unresolved evidence in the persisted runs.</p>
|
{!visibleReviews.length ? <p className="policy-analysis-muted">No conflicts or unresolved evidence in the persisted runs.</p>
|
||||||
: <div className="policy-review-list">{visibleReviews.map(item => <ReviewRow item={item} key={item.id}/>)}</div>}
|
: <div className="policy-review-list">{visibleReviews.map(item => <ReviewRow item={item} key={item.id}/>)}</div>}
|
||||||
{overview.reviewItems.length > visibleReviews.length && <p className="policy-analysis-muted">Showing the first 100 deterministic review hints.</p>}
|
{overview.current.reviewItems.length > visibleReviews.length && <p className="policy-analysis-muted">Showing the first 100 deterministic review hints.</p>}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import type { Assessment, CanonicalAssessmentModel } from '../../domain/canonical-model';
|
import type { Assessment, CanonicalAssessmentModel } from '../../domain/canonical-model';
|
||||||
import type { ImportedDataset } from '../../domain/dataset';
|
import type { ImportedDataset } from '../../domain/dataset';
|
||||||
|
import type { UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
|
||||||
|
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
|
||||||
import { canonicalModelsToDataset } from './canonical-model-to-dataset';
|
import { canonicalModelsToDataset } from './canonical-model-to-dataset';
|
||||||
|
import { appendPolicyObservationsToDataset } from './policy-observations-to-dataset';
|
||||||
|
|
||||||
export interface CanonicalProjectReader {
|
export interface CanonicalProjectReader {
|
||||||
listProjectAssessments(projectId: string): Promise<Assessment[]>;
|
listProjectAssessments(projectId: string): Promise<Assessment[]>;
|
||||||
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>;
|
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>;
|
||||||
|
queryUnifiedResults?(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanonicalProjectWorkspace {
|
export interface CanonicalProjectWorkspace {
|
||||||
models: CanonicalAssessmentModel[];
|
models: CanonicalAssessmentModel[];
|
||||||
dataset: ImportedDataset | null;
|
dataset: ImportedDataset | null;
|
||||||
|
assessmentIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canonicalModelForScanRuns(
|
export function canonicalModelForScanRuns(
|
||||||
|
|
@ -63,5 +68,18 @@ export async function loadCanonicalProjectWorkspace(
|
||||||
? loadedModels.map(model => canonicalModelForScanRuns(model, scanRunIds))
|
? loadedModels.map(model => canonicalModelForScanRuns(model, scanRunIds))
|
||||||
.filter((model): model is CanonicalAssessmentModel => model !== null)
|
.filter((model): model is CanonicalAssessmentModel => model !== null)
|
||||||
: loadedModels;
|
: loadedModels;
|
||||||
return { models, dataset: canonicalModelsToDataset(models) };
|
const assessmentIds = [...new Set(models.map(model => model.assessment.id))];
|
||||||
|
const policyObservations = reader.queryUnifiedResults && assessmentIds.length
|
||||||
|
? await queryAllUnifiedResults(
|
||||||
|
query => reader.queryUnifiedResults!(query),
|
||||||
|
{ projectId, assessmentIds, sourceKind: ['policy-observation'] }
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const scannerDataset = canonicalModelsToDataset(models);
|
||||||
|
const projectName = models[0]?.project.name ?? assessments.find(item => assessmentIds.includes(item.id))?.name ?? projectId;
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
assessmentIds,
|
||||||
|
dataset: appendPolicyObservationsToDataset(scannerDataset, projectName, policyObservations)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { ImportedDataset } from '../../domain/dataset';
|
||||||
|
import type { UnifiedResultProjection } from '../../domain/unified-result';
|
||||||
|
import { rowsToCsv } from '../../exports/csv-export';
|
||||||
|
import { makeSheet } from '../../importers/sheet-factory';
|
||||||
|
|
||||||
|
function display(value: unknown): string {
|
||||||
|
if (value === undefined) return '';
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendPolicyObservationsToDataset(
|
||||||
|
dataset: ImportedDataset | null,
|
||||||
|
projectName: string,
|
||||||
|
results: UnifiedResultProjection[]
|
||||||
|
): ImportedDataset | null {
|
||||||
|
const observations = results.filter(result => result.sourceKind === 'policy-observation');
|
||||||
|
if (!observations.length) return dataset;
|
||||||
|
const headers = [
|
||||||
|
'Title', 'Status', 'Scope', 'Delivery Mechanism', 'Policy State', 'Identity Status',
|
||||||
|
'Technical Identity', 'Configured Value', 'Diagnostics', 'Source Path', 'Backup Instance', 'Imported'
|
||||||
|
];
|
||||||
|
const observationRows = observations.map(result => [
|
||||||
|
result.title,
|
||||||
|
result.policyIdentityStatus === 'resolved' ? 'Observed' : 'Investigate',
|
||||||
|
result.policyScope ?? '',
|
||||||
|
result.policyDeliveryMechanism ?? '',
|
||||||
|
result.policyState ?? '',
|
||||||
|
result.policyIdentityStatus ?? 'unresolved',
|
||||||
|
result.policyIdentityKey ?? '',
|
||||||
|
display(result.policyValues),
|
||||||
|
result.policyDiagnosticCodes?.join(', ') ?? '',
|
||||||
|
result.sourceLocator?.objectPath ?? '',
|
||||||
|
result.policyBackupInstanceId ?? '',
|
||||||
|
result.importedAt
|
||||||
|
]);
|
||||||
|
const rows = [headers, ...observationRows];
|
||||||
|
const sheet = makeSheet(
|
||||||
|
'Group Policy · Observations',
|
||||||
|
rows,
|
||||||
|
rowsToCsv(rows),
|
||||||
|
projectName,
|
||||||
|
{ id: `gpo-policy-observations-${projectName}`, sourceFile: projectName, sourceSheet: 'Policy Observations', toolId: 'group-policy' }
|
||||||
|
);
|
||||||
|
if (!dataset) {
|
||||||
|
const lastModified = Math.max(...observations.map(result => Date.parse(result.importedAt)), 0);
|
||||||
|
return {
|
||||||
|
fileName: `${projectName} · Group Policy`,
|
||||||
|
fileSize: 0,
|
||||||
|
lastModified,
|
||||||
|
format: 'GPO Backup',
|
||||||
|
rawText: '',
|
||||||
|
sheets: [sheet],
|
||||||
|
profileId: 'generic',
|
||||||
|
profileVersion: 'gpo-policy-observation-1.0.0',
|
||||||
|
scanTool: 'Group Policy'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [first, ...rest] = dataset.sheets;
|
||||||
|
const aggregate = first?.isProjectSheet ? (() => {
|
||||||
|
const sourceHeaders = first.rows[0] ?? [];
|
||||||
|
const aggregateHeaders = [...sourceHeaders];
|
||||||
|
for (const header of headers) if (!aggregateHeaders.includes(header)) aggregateHeaders.push(header);
|
||||||
|
const projectRows = first.rows.slice(1).map(row => {
|
||||||
|
const source = new Map(sourceHeaders.map((header, index) => [header, row[index] ?? '']));
|
||||||
|
return aggregateHeaders.map(header => source.get(header) ?? '');
|
||||||
|
});
|
||||||
|
const policyRows = observationRows.map((row, rowIndex) => {
|
||||||
|
const source = new Map(headers.map((header, index) => [header, row[index] ?? '']));
|
||||||
|
source.set('Source: File', projectName);
|
||||||
|
source.set('Source: Sheet', 'Policy Observations');
|
||||||
|
source.set('Source: Scan', `Group Policy · ${observations[rowIndex].importedAt}`);
|
||||||
|
return aggregateHeaders.map(header => source.get(header) ?? '');
|
||||||
|
});
|
||||||
|
const aggregateRows = [aggregateHeaders, ...projectRows, ...policyRows];
|
||||||
|
return makeSheet(first.name, aggregateRows, rowsToCsv(aggregateRows), first.sourceFile ?? projectName, {
|
||||||
|
id: first.id,
|
||||||
|
sourceFile: first.sourceFile,
|
||||||
|
sourceSheet: first.sourceSheet,
|
||||||
|
isProjectSheet: true
|
||||||
|
});
|
||||||
|
})() : first;
|
||||||
|
return {
|
||||||
|
...dataset,
|
||||||
|
sheets: aggregate ? [aggregate, sheet, ...rest] : [sheet],
|
||||||
|
scanTool: dataset.scanTool === 'Group Policy' ? dataset.scanTool : 'Multiple Tools'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,9 @@ import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import type { UnifiedResultFacetResult, UnifiedResultProjection, UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
|
import type { UnifiedResultFacetResult, UnifiedResultProjection, UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
|
||||||
import { ResultsFilterPanel } from './ResultsFilterPanel';
|
import { ResultsFilterPanel } from './ResultsFilterPanel';
|
||||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
|
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
|
||||||
|
import { downloadText } from '../../exports/text-download';
|
||||||
|
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
|
||||||
|
|
||||||
const assessorApi = new AssessorApiClient();
|
const assessorApi = new AssessorApiClient();
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
@ -20,21 +23,24 @@ export function shouldShowNvdDescription(scannerDescription: string | undefined,
|
||||||
const normalizedNvd = comparisonKey(nvdDescription);
|
const normalizedNvd = comparisonKey(nvdDescription);
|
||||||
return normalizedScanner !== normalizedNvd;
|
return normalizedScanner !== normalizedNvd;
|
||||||
}
|
}
|
||||||
|
export const DEFAULT_RESULT_DB_FILTERS: UnifiedResultQuery = { deviation: true };
|
||||||
|
|
||||||
type BackendStatus = 'connected' | 'checking' | 'unavailable';
|
type BackendStatus = 'connected' | 'checking' | 'unavailable';
|
||||||
interface ResultsBrowserProps {
|
interface ResultsBrowserProps {
|
||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
scanRunIds?: string[];
|
scanRunIds?: string[];
|
||||||
|
assessmentIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: ResultsBrowserProps) {
|
export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds }: ResultsBrowserProps) {
|
||||||
const [filters, setFilters] = useState<UnifiedResultQuery>({});
|
const [filters, setFilters] = useState<UnifiedResultQuery>(() => ({ ...DEFAULT_RESULT_DB_FILTERS }));
|
||||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||||
const [loadAll, setLoadAll] = useState(false);
|
const [loadAll, setLoadAll] = useState(false);
|
||||||
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
||||||
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -51,7 +57,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
|
||||||
const scopedQuery: UnifiedResultQuery = {
|
const scopedQuery: UnifiedResultQuery = {
|
||||||
...filters,
|
...filters,
|
||||||
projectId,
|
projectId,
|
||||||
scanRunIds
|
scanRunIds,
|
||||||
|
assessmentIds
|
||||||
};
|
};
|
||||||
const [facets, firstPage] = await Promise.all([
|
const [facets, firstPage] = await Promise.all([
|
||||||
assessorApi.queryUnifiedResultFacets(scopedQuery),
|
assessorApi.queryUnifiedResultFacets(scopedQuery),
|
||||||
|
|
@ -74,7 +81,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [backendStatus, projectId, scanRunIds, filters, limit, loadAll]);
|
}, [backendStatus, projectId, scanRunIds, assessmentIds, filters, limit, loadAll]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
|
@ -84,6 +91,26 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
|
||||||
setLoadAll(false);
|
setLoadAll(false);
|
||||||
setFilters(next);
|
setFilters(next);
|
||||||
};
|
};
|
||||||
|
const exportFilteredResults = async () => {
|
||||||
|
if (!projectId || !scanRunIds?.length) return;
|
||||||
|
setExporting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const scopedQuery: UnifiedResultQuery = { ...filters, projectId, scanRunIds, assessmentIds };
|
||||||
|
const results = await queryAllUnifiedResults(query => assessorApi.queryUnifiedResults(query), scopedQuery);
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
downloadText(
|
||||||
|
browserDownloadAdapter,
|
||||||
|
unifiedResultJsonlFileName(generatedAt),
|
||||||
|
buildUnifiedResultJsonl(scopedQuery, results, generatedAt),
|
||||||
|
'application/x-ndjson;charset=utf-8'
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Filtered results could not be exported.');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const processingFacet = facetResult?.facets.find(facet => facet.id === 'processingStatus');
|
const processingFacet = facetResult?.facets.find(facet => facet.id === 'processingStatus');
|
||||||
const processingWarnings = processingFacet?.values.filter(value =>
|
const processingWarnings = processingFacet?.values.filter(value =>
|
||||||
['unknown', 'incompatible', 'reimport-recommended'].includes(value.value)) ?? [];
|
['unknown', 'incompatible', 'reimport-recommended'].includes(value.value)) ?? [];
|
||||||
|
|
@ -109,14 +136,19 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
|
||||||
return <div className="datahub-page results-browser-page">
|
return <div className="datahub-page results-browser-page">
|
||||||
{error &&<div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
|
{error &&<div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
|
||||||
{loading && !queryResult ? <div className="datahub-loading"><i/><span>Loading results …</span></div> : <div className="results-browser-layout">
|
{loading && !queryResult ? <div className="datahub-loading"><i/><span>Loading results …</span></div> : <div className="results-browser-layout">
|
||||||
<ResultsFilterPanel facets={facetResult?.facets ?? []} selected={filters} onChange={updateFilters}/>
|
<ResultsFilterPanel facets={facetResult?.facets ?? []} selected={filters} onChange={updateFilters}
|
||||||
|
onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })}/>
|
||||||
<div className="results-browser-table-wrap">
|
<div className="results-browser-table-wrap">
|
||||||
{processingWarnings.length > 0 && <div className="datahub-alert results-processing-alert" role="status">
|
{processingWarnings.length > 0 && <div className="datahub-alert results-processing-alert" role="status">
|
||||||
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
|
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="page-actions-row">
|
<div className="page-actions-row">
|
||||||
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}</p>
|
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}</p>
|
||||||
<button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
|
<div className="page-action-buttons">
|
||||||
|
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
|
||||||
|
<button className="btn primary" type="button" disabled={loading || exporting || !queryResult?.total}
|
||||||
|
onClick={() => void exportFilteredResults()}>{exporting ? 'Exporting …' : 'Export JSONL'}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
|
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
|
||||||
<div className="results-browser-row results-browser-head" role="row">
|
<div className="results-browser-row results-browser-head" role="row">
|
||||||
|
|
@ -143,6 +175,8 @@ function ResultRow({ item, expanded, onToggle, onUpdated }: {
|
||||||
onUpdated(): Promise<void>;
|
onUpdated(): Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const environment = item.sourceKind === 'environment-observation';
|
const environment = item.sourceKind === 'environment-observation';
|
||||||
|
const policyObservation = item.sourceKind === 'policy-observation';
|
||||||
|
const contextRecord = environment || policyObservation;
|
||||||
const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities'
|
const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities'
|
||||||
&& /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined;
|
&& /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined;
|
||||||
const [enriching, setEnriching] = useState(false);
|
const [enriching, setEnriching] = useState(false);
|
||||||
|
|
@ -163,11 +197,11 @@ function ResultRow({ item, expanded, onToggle, onUpdated }: {
|
||||||
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
|
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
|
||||||
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
|
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
|
||||||
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
|
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
|
||||||
<span data-label="Severity">{environment ? 'Context' : item.severityNormalized}</span>
|
<span data-label="Severity">{contextRecord ? 'Context' : item.severityNormalized}</span>
|
||||||
<span data-label="Outcome" className={environment ? '' : 'outcome-mark'} data-outcome={environment ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : item.outcomeNormalized}</span>
|
<span data-label="Outcome" className={contextRecord ? '' : 'outcome-mark'} data-outcome={contextRecord ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : policyObservation ? item.policyIdentityStatus : item.outcomeNormalized}</span>
|
||||||
<span data-label="Result Type">{environment ? 'Environment' : item.resultType}</span>
|
<span data-label="Result Type">{environment ? 'Environment' : policyObservation ? 'GPO Observation' : item.resultType}</span>
|
||||||
<span data-label="IOE">{environment ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
|
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
|
||||||
<span data-label="Deviation">{environment ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
||||||
<span data-label="Tool">{item.sourceTool ?? '–'}</span>
|
<span data-label="Tool">{item.sourceTool ?? '–'}</span>
|
||||||
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
|
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
|
||||||
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
|
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
|
||||||
|
|
@ -177,6 +211,10 @@ function ResultRow({ item, expanded, onToggle, onUpdated }: {
|
||||||
<strong>{item.environmentProperty}</strong>
|
<strong>{item.environmentProperty}</strong>
|
||||||
<p>{displayEnvironmentValue(item.environmentNormalizedValue)}</p>
|
<p>{displayEnvironmentValue(item.environmentNormalizedValue)}</p>
|
||||||
</div>}
|
</div>}
|
||||||
|
{policyObservation && <div className="results-environment-value">
|
||||||
|
<strong>{item.policyIdentityKey}</strong>
|
||||||
|
<p>{displayEnvironmentValue(item.policyValues)}</p>
|
||||||
|
</div>}
|
||||||
{item.description && <p>{item.description}</p>}
|
{item.description && <p>{item.description}</p>}
|
||||||
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -190,6 +228,12 @@ function ResultRow({ item, expanded, onToggle, onUpdated }: {
|
||||||
<li>Original value: {displayEnvironmentValue(item.environmentOriginalValue)}</li>
|
<li>Original value: {displayEnvironmentValue(item.environmentOriginalValue)}</li>
|
||||||
<li>Section: {item.environmentSectionKey ?? '–'}</li>
|
<li>Section: {item.environmentSectionKey ?? '–'}</li>
|
||||||
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
|
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
|
||||||
|
</> : policyObservation ? <>
|
||||||
|
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
|
||||||
|
<li>Policy state: {item.policyState ?? '–'} · Identity: {item.policyIdentityStatus ?? 'unresolved'}</li>
|
||||||
|
<li>Backup instance: {item.policyBackupInstanceId ?? '–'}</li>
|
||||||
|
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
|
||||||
|
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
|
||||||
</> : <>
|
</> : <>
|
||||||
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
|
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
|
||||||
{/* Defender-CVEs aggregieren betroffene Devices; der affectedAsset
|
{/* Defender-CVEs aggregieren betroffene Devices; der affectedAsset
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ interface ResultsFilterPanelProps {
|
||||||
facets: UnifiedResultFacet[];
|
facets: UnifiedResultFacet[];
|
||||||
selected: UnifiedResultQuery;
|
selected: UnifiedResultQuery;
|
||||||
onChange(next: UnifiedResultQuery): void;
|
onChange(next: UnifiedResultQuery): void;
|
||||||
|
onReset(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
|
type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
|
||||||
|
|
@ -59,7 +60,7 @@ function BooleanFacetFilter({ facet, value, label, onChange }: {
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsFilterPanel({ facets, selected, onChange }: ResultsFilterPanelProps) {
|
export function ResultsFilterPanel({ facets, selected, onChange, onReset }: ResultsFilterPanelProps) {
|
||||||
const deviationFacet = facets.find(facet => facet.id === 'deviation');
|
const deviationFacet = facets.find(facet => facet.id === 'deviation');
|
||||||
const isIoeFacet = facets.find(facet => facet.id === 'isIoe');
|
const isIoeFacet = facets.find(facet => facet.id === 'isIoe');
|
||||||
const categoryFacet = facets.find(facet => facet.id === 'category');
|
const categoryFacet = facets.find(facet => facet.id === 'category');
|
||||||
|
|
@ -128,6 +129,6 @@ export function ResultsFilterPanel({ facets, selected, onChange }: ResultsFilter
|
||||||
</ul>
|
</ul>
|
||||||
</section>)}
|
</section>)}
|
||||||
|
|
||||||
<button className="btn results-filter-reset" type="button" onClick={() => onChange({})}>Reset filters</button>
|
<button className="btn results-filter-reset" type="button" onClick={onReset}>Reset filters</button>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { normalizeComparisonText } from '../comparison/comparison-normalization'
|
||||||
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
|
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
|
||||||
import {
|
import {
|
||||||
createEnvironmentUnifiedResult,
|
createEnvironmentUnifiedResult,
|
||||||
|
createCurrentPolicyObservationUnifiedResults,
|
||||||
createPolicyUnifiedResult,
|
createPolicyUnifiedResult,
|
||||||
createScannerUnifiedResult,
|
createScannerUnifiedResult,
|
||||||
deriveUnifiedResultFacets,
|
deriveUnifiedResultFacets,
|
||||||
|
|
@ -601,7 +602,13 @@ export class LocalAssessorRepository implements AssessorRepository {
|
||||||
if (!scanRun || scanRun.supersededByScanRunId || !importFile || !assessment || !project) return [];
|
if (!scanRun || scanRun.supersededByScanRunId || !importFile || !assessment || !project) return [];
|
||||||
return [createEnvironmentUnifiedResult({ project, assessment, scanRun, importFile, observation })];
|
return [createEnvironmentUnifiedResult({ project, assessment, scanRun, importFile, observation })];
|
||||||
});
|
});
|
||||||
return [...this.unifiedScannerResults(), ...environment, ...policy];
|
const policyObservations = createCurrentPolicyObservationUnifiedResults(
|
||||||
|
this.state.importArtifacts,
|
||||||
|
this.state.processingRuns,
|
||||||
|
this.state.gpoBackupInstances,
|
||||||
|
this.state.gpoPolicyAnalyses
|
||||||
|
);
|
||||||
|
return [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policy];
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,20 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
.result-block__title { margin: 0 0 10px; color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
.result-block__title { margin: 0 0 10px; color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
||||||
.result-block__subtitle { margin: 0 0 10px; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-strong-weight); line-height: 1.4; }
|
.result-block__subtitle { margin: 0 0 10px; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-strong-weight); line-height: 1.4; }
|
||||||
.result-block__preamble { margin: 0 0 10px; color: var(--text); font-size: var(--ui-reading-size); line-height: 1.55; }
|
.result-block__preamble { margin: 0 0 10px; color: var(--text); font-size: var(--ui-reading-size); line-height: 1.55; }
|
||||||
|
.maester-error { display: grid; gap: var(--space-4); min-width: 0; }
|
||||||
|
.maester-error__summary { margin: 0; padding: var(--space-4); border-left: var(--bw-emphasis) solid var(--critical); color: var(--heading); background: color-mix(in srgb, var(--critical) 8%, transparent); font-size: var(--type-body-size); line-height: 1.55; }
|
||||||
|
.maester-error__facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--space-5); margin: 0; }
|
||||||
|
.maester-error__facts > div { display: grid; grid-template-columns: minmax(110px, .35fr) minmax(0, 1fr); gap: var(--space-3); padding: var(--space-2) 0; border-bottom: var(--border-hair) solid var(--line); }
|
||||||
|
.maester-error__facts dt { color: var(--muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
||||||
|
.maester-error__facts dd { min-width: 0; margin: 0; color: var(--text); font-size: var(--type-table-body-size); line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.maester-error__raw { min-width: 0; color: var(--muted); font-size: var(--type-button-size); }
|
||||||
|
.maester-error__raw summary { width: fit-content; cursor: pointer; color: var(--heading); font-weight: var(--fw-semibold); }
|
||||||
|
.maester-error__raw pre { max-width: 100%; margin: var(--space-3) 0 0; padding: var(--space-3); border: var(--border-hair) solid var(--line); color: var(--text); background: var(--surface-inset); font-family: inherit; font-size: var(--type-table-body-size); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.result-block > .rich-text-table-wrap:last-child { margin-bottom: 0; }
|
.result-block > .rich-text-table-wrap:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.maester-error__facts { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
/* Das Fenster nimmt die Hoehe seines Inhalts ein und waechst nur bis zur
|
/* Das Fenster nimmt die Hoehe seines Inhalts ein und waechst nur bis zur
|
||||||
Bildschirmgrenze; erst dann scrollt der Rumpf. Eine feste Hoehe liess bei
|
Bildschirmgrenze; erst dann scrollt der Rumpf. Eine feste Hoehe liess bei
|
||||||
kurzen Ergebnistabellen viel leeren Raum stehen. Manuelles Groessenaendern
|
kurzen Ergebnistabellen viel leeren Raum stehen. Manuelles Groessenaendern
|
||||||
|
|
|
||||||
|
|
@ -285,25 +285,24 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
|
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Client Info folgt dem SOC-Board mit genau zwei quadratischen
|
/* Client Info: Kennzahlen oben, darunter das dreispaltige Telemetrieraster
|
||||||
Kachelgroessen. Das gemeinsame dichte Raster nutzt auch Luecken neben den
|
mit einer eigenen rechten Verteilungsspalte. */
|
||||||
kleinen Kennzahlen, statt getrennte, ueber die Breite gestreckte Zeilen zu
|
|
||||||
erzeugen. */
|
|
||||||
.client-info-tile-grid {
|
.client-info-tile-grid {
|
||||||
--client-info-columns: 12;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--client-info-columns), minmax(0, 1fr));
|
|
||||||
grid-auto-rows: calc((100cqw - 11 * var(--segment-gap)) / 12);
|
|
||||||
grid-auto-flow: row dense;
|
|
||||||
gap: var(--segment-gap);
|
gap: var(--segment-gap);
|
||||||
}
|
}
|
||||||
.client-info-metric-grid {
|
.client-info-metric-grid {
|
||||||
display: contents;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: var(--segment-gap);
|
||||||
}
|
}
|
||||||
|
.client-info-top-row { display: grid; grid-template-columns: minmax(0, 3fr) minmax(320px, 1fr); align-items: stretch; gap: var(--segment-gap); min-width: 0; }
|
||||||
.client-info-metric {
|
.client-info-metric {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: space-between;
|
align-content: space-between;
|
||||||
|
align-self: start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -320,41 +319,83 @@ button.client-info-metric:hover { border-color: transparent; background: var(--t
|
||||||
button.client-info-metric:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
button.client-info-metric:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
.client-info-metric > span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.25; }
|
.client-info-metric > span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.25; }
|
||||||
.client-info-metric > strong { color: var(--chart-color, var(--heading)); font-size: calc(var(--type-h1-size) * .72); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
.client-info-top-row > .client-info-metric-grid .client-info-metric { align-self: stretch; height: 100%; min-height: 220px; aspect-ratio: auto; }
|
||||||
|
.client-info-metric > strong { color: var(--chart-color, var(--heading)); font-size: calc(var(--type-h1-size) * .84); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
.client-info-metric.is-emphasis > strong { font-size: var(--type-h1-size); font-weight: var(--fw-semibold); }
|
||||||
|
|
||||||
.client-info-card-grid {
|
.client-info-card-grid {
|
||||||
display: contents;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--segment-gap);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.client-info-card-column { display: flex; flex-direction: column; align-self: start; gap: var(--segment-gap); min-width: 0; }
|
||||||
|
.client-info-panels-layout { display: grid; grid-template-columns: minmax(0, 3fr) minmax(320px, 1fr); align-items: start; gap: var(--segment-gap); min-width: 0; }
|
||||||
|
.client-info-donut-rail { display: grid; align-content: start; gap: var(--segment-gap); min-width: 0; }
|
||||||
.client-info-card {
|
.client-info-card {
|
||||||
grid-column: span 2;
|
|
||||||
grid-row: span 2;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
aspect-ratio: 1;
|
min-height: min-content;
|
||||||
min-height: 0;
|
align-self: stretch;
|
||||||
padding: var(--space-3);
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--tile-surface);
|
background: var(--tile-surface);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
.client-info-card.is-collapsed { align-self: start; width: 100%; }
|
||||||
.client-info-card > header {
|
.client-info-card > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-5);
|
||||||
min-height: 24px;
|
min-height: 60px;
|
||||||
padding-bottom: 0;
|
padding: var(--space-3) var(--space-5);
|
||||||
border-bottom: 0;
|
border-bottom: var(--border-hair) solid var(--line);
|
||||||
}
|
}
|
||||||
.client-info-card > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; }
|
.client-info-card.is-collapsed > header { flex: 1 1 auto; border-bottom: 0; }
|
||||||
.client-info-card > header span { color: var(--muted); font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; text-align: right; }
|
.client-info-card-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-2); min-width: 0; }
|
||||||
|
.client-info-card-heading h2 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; }
|
||||||
|
.client-info-card-heading > span { color: var(--muted); font-size: var(--type-h4-size); font-variant-numeric: tabular-nums; line-height: 1.35; }
|
||||||
|
.client-info-card-heading .client-info-card-meta { font-size: var(--type-meta-size); }
|
||||||
|
.client-info-card-toggle { flex: 0 0 auto; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 0; color: var(--heading); background: transparent; cursor: pointer; }
|
||||||
|
.client-info-card-toggle .triangle-icon { width: 16px; height: 16px; }
|
||||||
|
.client-info-card-toggle:hover { border-color: var(--line-strong); background: var(--tile-surface-hover); }
|
||||||
|
.client-info-card-toggle:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 1px; }
|
||||||
|
.client-info-card-content { display: grid; align-content: start; min-width: 0; }
|
||||||
|
.client-info-card-details { display: grid; align-content: start; gap: 0; min-width: 0; padding: 0 var(--space-5) var(--space-3); }
|
||||||
|
.client-info-card-extra { display: grid; align-content: start; min-width: 0; padding: 0 var(--space-5) var(--space-4); border-top: var(--border-hair) solid var(--line); }
|
||||||
|
|
||||||
.client-info-donut-layout { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(130px, 1fr); align-items: center; gap: var(--space-3); flex: 1 1 auto; min-width: 0; }
|
.client-info-facts { display: grid; align-content: start; margin: 0; }
|
||||||
.client-info-donut { position: relative; width: min(160px, 100%); aspect-ratio: 1; justify-self: center; }
|
.client-info-facts > div { display: grid; grid-template-columns: minmax(110px, .8fr) minmax(0, 1.2fr); align-items: center; gap: var(--space-4); min-height: 56px; padding: var(--space-2) 0; border-bottom: var(--border-hair) solid var(--line); }
|
||||||
|
.client-info-facts > div:last-child { border-bottom: 0; }
|
||||||
|
.client-info-facts dt { color: var(--muted); font-size: var(--type-table-body-size); }
|
||||||
|
.client-info-facts dd { margin: 0; color: var(--heading); font-size: var(--type-table-body-size); line-height: 1.35; overflow-wrap: normal; word-break: normal; }
|
||||||
|
|
||||||
|
.client-info-bars { display: grid; align-content: start; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.client-info-bars li { min-width: 0; border-bottom: var(--border-hair) solid var(--line); }
|
||||||
|
.client-info-bars li:last-child { border-bottom: 0; }
|
||||||
|
.client-info-bar-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(48px, auto) minmax(120px, 1.15fr); grid-template-rows: auto auto; align-items: center; column-gap: var(--space-4); row-gap: var(--space-1); width: 100%; min-height: 64px; padding: var(--space-2) 0; border: 0; border-radius: 0; color: inherit; background: transparent; text-align: left; }
|
||||||
|
button.client-info-bar-row { cursor: pointer; }
|
||||||
|
button.client-info-bar-row:hover { background: var(--tile-surface-hover); }
|
||||||
|
button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
||||||
|
.client-info-bar-label { grid-column: 1; grid-row: 1 / -1; align-self: center; color: var(--text); font-size: var(--type-body-size); line-height: 1.4; overflow-wrap: normal; word-break: normal; }
|
||||||
|
.client-info-bar-track { grid-column: 3; grid-row: 1 / -1; width: 100%; height: 30px; background: var(--surface-active); }
|
||||||
|
.client-info-bar-track i { --share: 0%; display: block; width: var(--share); min-width: 2px; height: 100%; background: var(--chart-color, var(--accent)); }
|
||||||
|
.client-info-bars strong { grid-column: 2; grid-row: 1; display: flex; align-items: baseline; justify-content: flex-end; gap: var(--space-1); min-width: 0; color: var(--heading); font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||||
|
.client-info-bars .client-info-bar-detail { color: var(--heading); font-size: var(--type-meta-size); font-weight: var(--fw-semibold); }
|
||||||
|
.client-info-bars .client-info-bar-status { grid-column: 2; grid-row: 2; min-height: 12px; color: var(--warning); font-size: var(--type-meta-size); line-height: 1; text-align: right; }
|
||||||
|
.client-info-bars .client-info-bar-status.is-empty { visibility: hidden; }
|
||||||
|
.client-info-card-caption { margin: 0; padding: var(--space-4) 0 0; border-top: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
|
.client-info-card-extra .client-info-card-caption { border-top: 0; }
|
||||||
|
|
||||||
|
.client-info-donut-card .client-info-card-details { padding: var(--space-5); }
|
||||||
|
.client-info-donut-layout { display: grid; grid-template-columns: minmax(130px, .9fr) minmax(145px, 1.1fr); align-items: center; gap: var(--space-4); min-width: 0; }
|
||||||
|
.client-info-donut { position: relative; width: min(172px, 100%); aspect-ratio: 1; justify-self: center; }
|
||||||
.client-info-donut svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }
|
.client-info-donut svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }
|
||||||
.client-info-donut circle { fill: none; stroke-width: var(--donut-ring-stroke); }
|
.client-info-donut circle { fill: none; stroke-width: var(--donut-ring-stroke); }
|
||||||
.client-info-donut-track { stroke: var(--donut-track); }
|
.client-info-donut-track { stroke: var(--donut-track); }
|
||||||
|
|
@ -363,39 +404,14 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
.client-info-donut > span strong { font-size: calc(var(--type-h2-size) * 1.08); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
.client-info-donut > span strong { font-size: calc(var(--type-h2-size) * 1.08); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
.client-info-donut > span small { margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
.client-info-donut > span small { margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
.client-info-legend { display: grid; align-content: center; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
.client-info-legend { display: grid; align-content: center; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||||
.client-info-legend button, .client-info-legend-row { display: grid; grid-template-columns: 11px minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); width: 100%; min-height: 36px; padding: var(--space-1) var(--space-2); border: 0; border-radius: var(--btn-radius); color: var(--text); background: transparent; text-align: left; }
|
.client-info-legend button, .client-info-legend-row { display: grid; grid-template-columns: 11px minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); width: 100%; min-height: 36px; padding: var(--space-1) var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; }
|
||||||
.client-info-legend button { cursor: pointer; }
|
.client-info-legend button { cursor: pointer; }
|
||||||
.client-info-legend button:hover { background: var(--panel-hover); }
|
.client-info-legend button:hover { background: var(--tile-surface-hover); }
|
||||||
.client-info-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
.client-info-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
||||||
.client-info-legend i { width: 11px; height: 11px; background: var(--chart-color, var(--muted)); }
|
.client-info-legend i { width: 11px; height: 11px; background: var(--chart-color, var(--muted)); }
|
||||||
.client-info-legend span { min-width: 0; font-size: var(--type-table-body-size); line-height: 1.25; overflow-wrap: normal; word-break: normal; }
|
.client-info-legend span { min-width: 0; font-size: var(--type-table-body-size); line-height: 1.25; overflow-wrap: normal; word-break: normal; }
|
||||||
.client-info-legend strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
.client-info-legend strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.client-info-facts { display: grid; align-content: start; margin: 0; }
|
|
||||||
.client-info-facts > div { display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); align-items: baseline; gap: var(--space-3); min-height: 32px; padding: var(--space-1) 0; border-bottom: var(--border-hair) solid var(--line); }
|
|
||||||
.client-info-facts > div:last-child { border-bottom: 0; }
|
|
||||||
.client-info-facts dt { color: var(--muted); font-size: var(--type-table-body-size); }
|
|
||||||
.client-info-facts dd { margin: 0; color: var(--heading); font-size: var(--type-table-body-size); line-height: 1.35; overflow-wrap: normal; word-break: normal; }
|
|
||||||
|
|
||||||
.client-info-bars { display: grid; align-content: start; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
|
||||||
.client-info-bars li { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(50px, .9fr) auto; align-items: center; gap: var(--space-2); min-height: 36px; }
|
|
||||||
.client-info-bars li > span:first-child { color: var(--text); font-size: var(--type-table-body-size); line-height: 1.25; }
|
|
||||||
.client-info-bar-track { height: 14px; background: var(--surface-active); }
|
|
||||||
.client-info-bar-track i { --share: 0%; display: block; width: var(--share); min-width: 2px; height: 100%; background: var(--chart-color, var(--accent)); }
|
|
||||||
.client-info-bars strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; }
|
|
||||||
.client-info-bars small { grid-column: 3; color: var(--warning); font-size: var(--type-meta-size); text-align: right; }
|
|
||||||
.client-info-card-caption { margin: auto 0 0; padding-top: var(--space-3); border-top: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--type-meta-size); }
|
|
||||||
.client-info-resilience { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); gap: var(--space-3); }
|
|
||||||
.client-info-resilience > header { grid-column: 1; }
|
|
||||||
.client-info-resilience .client-info-bars { grid-column: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); align-self: start; gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: var(--border-hair) solid var(--line); }
|
|
||||||
.client-info-resilience .client-info-bars li { display: grid; grid-template-columns: minmax(0, 1fr); align-content: space-between; gap: var(--space-1); min-height: 58px; padding: var(--space-1); border-right: var(--border-hair) solid var(--line); }
|
|
||||||
.client-info-resilience .client-info-bars li:last-child { border-right: 0; }
|
|
||||||
.client-info-resilience .client-info-bars li > span:first-child { font-size: var(--type-meta-size); }
|
|
||||||
.client-info-resilience .client-info-bar-track { display: none; }
|
|
||||||
.client-info-resilience .client-info-bars strong { grid-column: 1; text-align: left; }
|
|
||||||
.client-info-resilience .client-info-facts { grid-column: 1; align-self: start; }
|
|
||||||
.client-info-resilience .client-info-facts > div { grid-template-columns: minmax(90px, .8fr) minmax(0, 1.2fr); gap: var(--space-2); min-height: 24px; padding: var(--space-1) 0; border-bottom: 0; }
|
|
||||||
|
|
||||||
.tone-deviation, .tone-error, .tone-critical { --chart-color: var(--critical); }
|
.tone-deviation, .tone-error, .tone-critical { --chart-color: var(--critical); }
|
||||||
.tone-passed, .tone-low, .tone-coverage { --chart-color: var(--success); }
|
.tone-passed, .tone-low, .tone-coverage { --chart-color: var(--success); }
|
||||||
.tone-high { --chart-color: var(--high); }
|
.tone-high { --chart-color: var(--high); }
|
||||||
|
|
@ -406,38 +422,31 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
.tone-gpo { --chart-color: var(--high); }
|
.tone-gpo { --chart-color: var(--high); }
|
||||||
.tone-risk { --chart-color: var(--warning); }
|
.tone-risk { --chart-color: var(--warning); }
|
||||||
|
|
||||||
@media (max-width: 1800px) {
|
|
||||||
.client-info-tile-grid {
|
|
||||||
--client-info-columns: 10;
|
|
||||||
grid-auto-rows: calc((100cqw - 9 * var(--segment-gap)) / 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.client-info-tile-grid {
|
.client-info-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
--client-info-columns: 8;
|
|
||||||
grid-auto-rows: calc((100cqw - 7 * var(--segment-gap)) / 8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.client-info-tile-grid {
|
.client-info-top-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||||
--client-info-columns: 6;
|
.client-info-metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
grid-auto-rows: calc((100cqw - 5 * var(--segment-gap)) / 6);
|
.client-info-top-row > .client-info-metric-grid .client-info-metric { height: auto; min-height: 0; aspect-ratio: 1; }
|
||||||
}
|
.client-info-panels-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.client-info-donut-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.client-info-tile-grid {
|
.client-info-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
--client-info-columns: 4;
|
.client-info-card-grid, .client-info-donut-rail { grid-template-columns: minmax(0, 1fr); }
|
||||||
grid-auto-rows: calc((100cqw - 3 * var(--segment-gap)) / 4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.client-info-tile-grid {
|
.client-info-card > header { align-items: flex-start; min-height: 68px; padding: var(--space-3); }
|
||||||
--client-info-columns: 2;
|
.client-info-card-heading { display: grid; gap: var(--space-1); }
|
||||||
grid-auto-rows: calc((100cqw - var(--segment-gap)) / 2);
|
.client-info-card-details, .client-info-card-extra { padding-left: var(--space-3); padding-right: var(--space-3); }
|
||||||
}
|
.client-info-bar-row { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto auto; min-height: 132px; column-gap: var(--space-3); }
|
||||||
|
.client-info-bar-label { grid-column: 1; grid-row: 1; }
|
||||||
|
.client-info-bars strong { grid-column: 2; grid-row: 1; }
|
||||||
|
.client-info-bars .client-info-bar-status { grid-column: 1 / -1; grid-row: 2; text-align: left; }
|
||||||
|
.client-info-bar-track { grid-column: 1 / -1; grid-row: 3; height: 44px; }
|
||||||
|
.client-info-facts > div { grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); gap: var(--space-3); }
|
||||||
.client-info-donut-layout { grid-template-columns: minmax(0, 1fr); }
|
.client-info-donut-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
.client-info-bars li { grid-template-columns: minmax(110px, 1fr) minmax(60px, 1fr) auto; }
|
|
||||||
.client-info-resilience .client-info-bars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DB-Struktur panel */
|
/* DB-Struktur panel */
|
||||||
|
|
@ -447,6 +456,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
eigenen Aussenabstand. */
|
eigenen Aussenabstand. */
|
||||||
.db-structure-meta { margin: 0; color: var(--muted); font-size: var(--ui-body-size); }
|
.db-structure-meta { margin: 0; color: var(--muted); font-size: var(--ui-body-size); }
|
||||||
.page-actions-row { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-4); margin-top: var(--legacy-space-4); }
|
.page-actions-row { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-4); margin-top: var(--legacy-space-4); }
|
||||||
|
.page-action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--legacy-space-2); }
|
||||||
.db-structure-layers { display: grid; gap: var(--segment-gap); margin-top: var(--legacy-space-5); width: 100%; }
|
.db-structure-layers { display: grid; gap: var(--segment-gap); margin-top: var(--legacy-space-5); width: 100%; }
|
||||||
.db-structure-layer { padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-left: 3px solid var(--legacy-accent); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.db-structure-layer { padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-left: 3px solid var(--legacy-accent); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.db-structure-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--legacy-space-2); }
|
.db-structure-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--legacy-space-2); }
|
||||||
|
|
@ -846,6 +856,9 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
width: min(794px, 100%);
|
width: min(794px, 100%);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
.tenant-policy-information {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.tenant-detail-page .tenant-editor-header {
|
.tenant-detail-page .tenant-editor-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|
@ -1078,11 +1091,18 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
.tenant-report-status[data-status="recognized"]::before { background: var(--state-done); }
|
.tenant-report-status[data-status="recognized"]::before { background: var(--state-done); }
|
||||||
|
.tenant-report-status[data-status="succeeded"]::before { background: var(--state-done); }
|
||||||
.tenant-report-status[data-status="generic"]::before { background: var(--outcome-warning); }
|
.tenant-report-status[data-status="generic"]::before { background: var(--outcome-warning); }
|
||||||
|
.tenant-report-status[data-status="queued"]::before,
|
||||||
|
.tenant-report-status[data-status="running"]::before { background: var(--info); }
|
||||||
|
.tenant-report-status[data-status="partial"]::before,
|
||||||
|
.tenant-report-status[data-status="recovery-required"]::before { background: var(--warning); }
|
||||||
.tenant-report-status[data-status="not-viewable"]::before,
|
.tenant-report-status[data-status="not-viewable"]::before,
|
||||||
.tenant-report-status[data-status="unavailable"]::before,
|
.tenant-report-status[data-status="unavailable"]::before,
|
||||||
.tenant-report-status[data-status="processing-error"]::before { background: var(--state-offline); }
|
.tenant-report-status[data-status="processing-error"]::before,
|
||||||
|
.tenant-report-status[data-status="dead-letter"]::before { background: var(--state-offline); }
|
||||||
.tenant-report-status[data-status="processing-error"] { color: var(--state-offline); }
|
.tenant-report-status[data-status="processing-error"] { color: var(--state-offline); }
|
||||||
|
.tenant-report-status[data-status="dead-letter"] { color: var(--state-offline); }
|
||||||
.tenant-report-status[data-status="reimport-recommended"]::before { background: var(--warning); }
|
.tenant-report-status[data-status="reimport-recommended"]::before { background: var(--warning); }
|
||||||
.tenant-report-status[data-status="reimport-recommended"] { color: var(--warning); }
|
.tenant-report-status[data-status="reimport-recommended"] { color: var(--warning); }
|
||||||
.tenant-processing-alert { display: grid; gap: var(--space-3); margin: var(--space-4) var(--space-5); padding: var(--space-4); border: var(--bw-hairline) solid var(--warning); color: var(--text-primary); background: color-mix(in srgb, var(--warning) 8%, var(--surface-panel)); }
|
.tenant-processing-alert { display: grid; gap: var(--space-3); margin: var(--space-4) var(--space-5); padding: var(--space-4); border: var(--bw-hairline) solid var(--warning); color: var(--text-primary); background: color-mix(in srgb, var(--warning) 8%, var(--surface-panel)); }
|
||||||
|
|
@ -1102,9 +1122,43 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
min-width: calc(var(--space-8) * 2);
|
min-width: calc(var(--space-8) * 2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.tenant-report-gpo .tenant-report-copy b,
|
||||||
|
.tenant-report-gpo .tenant-report-copy small {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.tenant-gpo-source-marker {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: var(--space-5);
|
||||||
|
height: var(--space-5);
|
||||||
|
color: var(--surface-app);
|
||||||
|
background: var(--marking-color);
|
||||||
|
font-size: var(--type-badge-size);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
}
|
||||||
|
.tenant-report-row .tenant-gpo-view {
|
||||||
|
min-width: calc(var(--space-8) * 2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tenant-gpo-row-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.tenant-gpo-row-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: var(--space-1);
|
||||||
|
}
|
||||||
|
.tenant-report-no-action {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.tenant-timeslot-actions {
|
.tenant-timeslot-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, .85fr) minmax(0, 1fr) minmax(0, 1.65fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-8);
|
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-8);
|
||||||
|
|
@ -1118,6 +1172,14 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.tenant-timeslot-actions .gpo-folder-actions {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.tenant-timeslot-actions .gpo-folder-feedback {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
.tenant-files-empty {
|
.tenant-files-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--legacy-space-3);
|
gap: var(--legacy-space-3);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 112px;
|
min-height: var(--tile-content-min-height);
|
||||||
padding: var(--legacy-space-4);
|
padding: var(--legacy-space-4);
|
||||||
border: var(--border-hair) solid var(--tile-border);
|
border: var(--border-hair) solid var(--tile-border);
|
||||||
border-radius: var(--tile-radius);
|
border-radius: var(--tile-radius);
|
||||||
|
|
@ -342,6 +342,19 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
Rumpfes erbte und damit groesser stand als dieselbe Aussage in der
|
Rumpfes erbte und damit groesser stand als dieselbe Aussage in der
|
||||||
Trefferliste. Die Plaketten daneben tragen ihre eigene Groesse. */
|
Trefferliste. Die Plaketten daneben tragen ihre eigene Groesse. */
|
||||||
.data-tile-badges { display: flex; flex-wrap: wrap; gap: var(--legacy-space-2); margin-top: var(--legacy-space-4); font-size: var(--ui-control-size); }
|
.data-tile-badges { display: flex; flex-wrap: wrap; gap: var(--legacy-space-2); margin-top: var(--legacy-space-4); font-size: var(--ui-control-size); }
|
||||||
|
/* Die regulaere Work-Kachel folgt einer festen semantischen Badge-Matrix:
|
||||||
|
Status, Kategorien, Severity/Weight und Score. Graph-Micro-Cards behalten
|
||||||
|
ihre eigene kompakte Flex-Anordnung. */
|
||||||
|
.tile-grid > .data-tile .data-tile-badges {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content max-content;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
.tile-grid > .data-tile .data-tile-badges > * { max-width: 100%; overflow-wrap: normal; word-break: normal; }
|
||||||
|
.tile-grid > .data-tile [data-badge-role="status"],
|
||||||
|
.tile-grid > .data-tile [data-badge-role="category"],
|
||||||
|
.tile-grid > .data-tile [data-badge-role="score"] { grid-column: 1 / -1; justify-self: start; }
|
||||||
.data-tile-actions { display: grid; gap: var(--legacy-space-2); align-content: start; }
|
.data-tile-actions { display: grid; gap: var(--legacy-space-2); align-content: start; }
|
||||||
.tile-bookmark-btn, .tile-results-btn {
|
.tile-bookmark-btn, .tile-results-btn {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@
|
||||||
--tile-radius: var(--radius-sm);
|
--tile-radius: var(--radius-sm);
|
||||||
--tile-inset: var(--space-3);
|
--tile-inset: var(--space-3);
|
||||||
--tile-compact-width: 186px;
|
--tile-compact-width: 186px;
|
||||||
--tile-content-width: 320px;
|
--tile-content-width: 358px;
|
||||||
|
--tile-content-min-height: 223px;
|
||||||
--tile-card-shadow: none;
|
--tile-card-shadow: none;
|
||||||
|
|
||||||
--donut-rack-surface: var(--segment-surface);
|
--donut-rack-surface: var(--segment-surface);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
|
||||||
|
import type { ImportArtifact, ImportProcessingRun } from '../../src/domain/ingestion-persistence';
|
||||||
|
import {
|
||||||
|
createCurrentPolicyObservationUnifiedResults,
|
||||||
|
deriveUnifiedResultFacets,
|
||||||
|
queryUnifiedResultItems
|
||||||
|
} from '../../src/domain/unified-result';
|
||||||
|
|
||||||
|
const timestamp = '2026-09-03T12:00:00.000Z';
|
||||||
|
const artifact: ImportArtifact = {
|
||||||
|
version: 1,
|
||||||
|
id: 'artifact-gpo-synthetic',
|
||||||
|
projectId: 'project-gpo-synthetic',
|
||||||
|
assessmentId: 'assessment-gpo-synthetic',
|
||||||
|
kind: 'source-file',
|
||||||
|
relativePath: 'synthetic-gpo.zip',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
sizeBytes: 100,
|
||||||
|
sensitivity: 'secret',
|
||||||
|
parseStatus: 'partial',
|
||||||
|
storage: { kind: 'not-retained', reason: 'synthetic' },
|
||||||
|
createdAt: timestamp
|
||||||
|
};
|
||||||
|
const processing: ImportProcessingRun = {
|
||||||
|
version: 1,
|
||||||
|
id: 'processing-gpo-synthetic',
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
artifactId: artifact.id,
|
||||||
|
status: 'partial',
|
||||||
|
processors: [{ kind: 'artifact-parser', id: 'synthetic-gpo-parser', version: '1.0.0' }],
|
||||||
|
diagnosticCodes: [],
|
||||||
|
startedAt: timestamp,
|
||||||
|
completedAt: timestamp
|
||||||
|
};
|
||||||
|
const backup: StoredGpoBackupInstance = {
|
||||||
|
version: 1,
|
||||||
|
id: 'stored-backup-gpo-synthetic',
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
artifactId: artifact.id,
|
||||||
|
processingRunId: processing.id,
|
||||||
|
createdAt: timestamp,
|
||||||
|
instance: {
|
||||||
|
version: 1,
|
||||||
|
id: 'backup-gpo-synthetic',
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
sourceArtifactId: artifact.id,
|
||||||
|
identity: { status: 'unresolved', reasonCode: 'synthetic' },
|
||||||
|
domain: { status: 'unknown' },
|
||||||
|
collection: { status: 'unknown' },
|
||||||
|
backupSet: { scope: 'selected-gpos', evidence: 'user-selection' },
|
||||||
|
metadataStatus: 'partial',
|
||||||
|
sections: { computer: 'present', user: 'unknown' },
|
||||||
|
inventory: {
|
||||||
|
version: 1,
|
||||||
|
status: 'partial',
|
||||||
|
diagnosticCodes: [],
|
||||||
|
entries: [{
|
||||||
|
version: 1,
|
||||||
|
artifactId: 'entry-gpo-synthetic',
|
||||||
|
relativePath: 'DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf',
|
||||||
|
role: 'security-template',
|
||||||
|
scope: 'computer',
|
||||||
|
disposition: 'supported',
|
||||||
|
secretIndicatorStatus: 'not-detected'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
policyObservations: [{
|
||||||
|
version: 1,
|
||||||
|
id: 'observation-gpo-synthetic',
|
||||||
|
backupInstanceId: 'backup-gpo-synthetic',
|
||||||
|
sourceArtifactId: artifact.id,
|
||||||
|
artifactId: 'entry-gpo-synthetic',
|
||||||
|
sourcePath: 'DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf',
|
||||||
|
sourceSha256: 'b'.repeat(64),
|
||||||
|
scope: 'computer',
|
||||||
|
deliveryMechanism: 'security-template',
|
||||||
|
parser: { id: 'security-template-parser', version: '1.0.0' },
|
||||||
|
reducer: { id: 'security-template-reducer', version: '1.0.0' },
|
||||||
|
identity: { status: 'unresolved', namespace: 'security-template', section: 'System Access', key: 'MinimumPasswordLength' },
|
||||||
|
state: 'configured',
|
||||||
|
values: [{ kind: 'scalar', raw: '14' }],
|
||||||
|
sourceLines: [3],
|
||||||
|
effectiveState: 'unknown',
|
||||||
|
diagnosticCodes: []
|
||||||
|
}],
|
||||||
|
discoveredAt: timestamp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const analysis: StoredGpoPolicyAnalysis = {
|
||||||
|
run: {
|
||||||
|
version: 1,
|
||||||
|
id: 'analysis-gpo-synthetic',
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
artifactId: artifact.id,
|
||||||
|
sourceProcessingRunId: processing.id,
|
||||||
|
analyzer: { id: 'gpo-policy-identity-analysis', version: '1.0.0' },
|
||||||
|
identityResolver: { id: 'synthetic-resolver', version: '1.0.0' },
|
||||||
|
status: 'partial',
|
||||||
|
observationCount: 1,
|
||||||
|
resolvedCount: 0,
|
||||||
|
diagnosticCodes: ['identity-review-required'],
|
||||||
|
startedAt: timestamp,
|
||||||
|
completedAt: timestamp
|
||||||
|
},
|
||||||
|
resolutions: [{
|
||||||
|
version: 1,
|
||||||
|
id: 'resolution-gpo-synthetic',
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
analysisRunId: 'analysis-gpo-synthetic',
|
||||||
|
sourceProcessingRunId: processing.id,
|
||||||
|
backupInstanceId: 'backup-gpo-synthetic',
|
||||||
|
observationId: 'observation-gpo-synthetic',
|
||||||
|
resolution: { version: 1, status: 'unresolved', reasonCode: 'identity-resolver-unsupported-security-template' }
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GPO observations in the unified result read model', () => {
|
||||||
|
it('keeps raw policy evidence visible in its assessment without claiming a deviation', () => {
|
||||||
|
const results = createCurrentPolicyObservationUnifiedResults([artifact], [processing], [backup], [analysis]);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]).toMatchObject({
|
||||||
|
resultProjectionId: 'policy-observation:observation-gpo-synthetic',
|
||||||
|
sourceKind: 'policy-observation',
|
||||||
|
assessmentId: artifact.assessmentId,
|
||||||
|
sourceTool: 'group-policy',
|
||||||
|
title: 'System Access · MinimumPasswordLength',
|
||||||
|
resultType: 'informational',
|
||||||
|
isDeviation: false,
|
||||||
|
policyIdentityStatus: 'unresolved',
|
||||||
|
policyValues: [{ kind: 'scalar', raw: '14' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryUnifiedResultItems(results, {
|
||||||
|
projectId: artifact.projectId,
|
||||||
|
scanRunIds: ['scanner-run-in-same-timeslot'],
|
||||||
|
assessmentIds: [artifact.assessmentId!],
|
||||||
|
deviation: true
|
||||||
|
}).total).toBe(1);
|
||||||
|
expect(queryUnifiedResultItems(results, { assessmentIds: ['another-assessment'] }).total).toBe(0);
|
||||||
|
expect(deriveUnifiedResultFacets(results, { assessmentIds: [artifact.assessmentId!] })
|
||||||
|
.facets.find(facet => facet.id === 'sourceKind')?.values)
|
||||||
|
.toContainEqual({ value: 'policy-observation', label: 'policy-observation', count: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -108,6 +108,9 @@ describe('policy analysis overview', () => {
|
||||||
expect(overview.summary.unresolvedIdentities).toBe(1);
|
expect(overview.summary.unresolvedIdentities).toBe(1);
|
||||||
expect(overview.summary.catalogGaps).toBe(1);
|
expect(overview.summary.catalogGaps).toBe(1);
|
||||||
expect(overview.summary.partialRuns).toBe(4);
|
expect(overview.summary.partialRuns).toBe(4);
|
||||||
|
expect(overview.current.summary).toEqual(overview.summary);
|
||||||
|
expect(overview.current.evaluationRunIds).toEqual(['evaluation-1']);
|
||||||
|
expect(overview.current.findingRunIds).toEqual(['finding-run-1']);
|
||||||
expect(overview.reviewItems.map(item => item.category)).toEqual(expect.arrayContaining([
|
expect(overview.reviewItems.map(item => item.category)).toEqual(expect.arrayContaining([
|
||||||
'conflict', 'unresolved-identity', 'catalog-gap', 'partial-analysis',
|
'conflict', 'unresolved-identity', 'catalog-gap', 'partial-analysis',
|
||||||
'targeting-unknown', 'incomplete-evidence'
|
'targeting-unknown', 'incomplete-evidence'
|
||||||
|
|
@ -123,7 +126,106 @@ describe('policy analysis overview', () => {
|
||||||
expect(overview).toMatchObject({
|
expect(overview).toMatchObject({
|
||||||
version: 1, projectId: 'empty-project', generatedAt: timestamp,
|
version: 1, projectId: 'empty-project', generatedAt: timestamp,
|
||||||
summary: { policyFindings: 0, reviewItems: 0, conflicts: 0, partialRuns: 0 },
|
summary: { policyFindings: 0, reviewItems: 0, conflicts: 0, partialRuns: 0 },
|
||||||
|
current: {
|
||||||
|
version: 1,
|
||||||
|
summary: { policyFindings: 0, reviewItems: 0, conflicts: 0, partialRuns: 0 },
|
||||||
|
evaluationRunIds: [], findingRunIds: [], reviewItems: []
|
||||||
|
},
|
||||||
evaluations: [], reviewItems: []
|
evaluations: [], reviewItems: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps superseded processing chains in history but excludes them from the current snapshot', () => {
|
||||||
|
const input = syntheticInput();
|
||||||
|
const currentTimestamp = '2026-08-05T15:00:00.000Z';
|
||||||
|
input.identityAnalyses.push({
|
||||||
|
run: {
|
||||||
|
...input.identityAnalyses[0].run,
|
||||||
|
id: 'identity-run-2',
|
||||||
|
sourceProcessingRunId: 'processing-2',
|
||||||
|
supersedesAnalysisRunId: 'identity-run-1',
|
||||||
|
status: 'complete',
|
||||||
|
resolvedCount: 1,
|
||||||
|
diagnosticCodes: [],
|
||||||
|
startedAt: currentTimestamp,
|
||||||
|
completedAt: currentTimestamp
|
||||||
|
},
|
||||||
|
resolutions: []
|
||||||
|
});
|
||||||
|
input.aggregations.push({
|
||||||
|
...input.aggregations[0],
|
||||||
|
run: {
|
||||||
|
...input.aggregations[0].run,
|
||||||
|
id: 'aggregation-2',
|
||||||
|
sourceAnalysisRunIds: ['identity-run-2'],
|
||||||
|
snapshotId: 'snapshot-2',
|
||||||
|
status: 'complete',
|
||||||
|
conflictCount: 0,
|
||||||
|
unresolvedIdentityCount: 0,
|
||||||
|
reviewHintCount: 0,
|
||||||
|
diagnosticCodes: [],
|
||||||
|
startedAt: currentTimestamp,
|
||||||
|
completedAt: currentTimestamp
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
...input.aggregations[0].snapshot,
|
||||||
|
id: 'snapshot-2',
|
||||||
|
status: 'consistent',
|
||||||
|
completeness: 'complete'
|
||||||
|
},
|
||||||
|
states: [],
|
||||||
|
reviewHints: []
|
||||||
|
});
|
||||||
|
input.evaluations.push({
|
||||||
|
...input.evaluations[0],
|
||||||
|
run: {
|
||||||
|
...input.evaluations[0].run,
|
||||||
|
id: 'evaluation-2',
|
||||||
|
aggregationRunId: 'aggregation-2',
|
||||||
|
status: 'complete',
|
||||||
|
matchCount: 3,
|
||||||
|
deviationCount: 2,
|
||||||
|
reviewCount: 0,
|
||||||
|
diagnosticCodes: [],
|
||||||
|
startedAt: currentTimestamp,
|
||||||
|
completedAt: currentTimestamp
|
||||||
|
},
|
||||||
|
results: [],
|
||||||
|
evidence: []
|
||||||
|
});
|
||||||
|
input.findingRuns.push({
|
||||||
|
...input.findingRuns[0],
|
||||||
|
run: {
|
||||||
|
...input.findingRuns[0].run,
|
||||||
|
id: 'finding-run-2',
|
||||||
|
evaluationRunId: 'evaluation-2',
|
||||||
|
status: 'complete',
|
||||||
|
eligibleResultCount: 2,
|
||||||
|
findingCount: 2,
|
||||||
|
rejectedResultCount: 0,
|
||||||
|
startedAt: currentTimestamp,
|
||||||
|
completedAt: currentTimestamp
|
||||||
|
},
|
||||||
|
occurrences: [{}, {}] as never[],
|
||||||
|
rejections: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = buildPolicyAnalysisOverview(input);
|
||||||
|
|
||||||
|
expect(overview.summary).toMatchObject({
|
||||||
|
identityAnalysisRuns: 2, aggregationRuns: 2, evaluationRuns: 2, findingRuns: 2,
|
||||||
|
confirmedDeviations: 3, policyFindings: 3
|
||||||
|
});
|
||||||
|
expect(overview.current.summary).toMatchObject({
|
||||||
|
identityAnalysisRuns: 1, aggregationRuns: 1, evaluationRuns: 1, findingRuns: 1,
|
||||||
|
configuredMatches: 3, confirmedDeviations: 2, policyFindings: 2,
|
||||||
|
reviewItems: 0, conflicts: 0, unresolvedIdentities: 0, partialRuns: 0
|
||||||
|
});
|
||||||
|
expect(overview.current.evaluationRunIds).toEqual(['evaluation-2']);
|
||||||
|
expect(overview.current.findingRunIds).toEqual(['finding-run-2']);
|
||||||
|
expect(overview.current.reviewItems).toEqual([]);
|
||||||
|
expect(overview.reviewItems.length).toBeGreaterThan(0);
|
||||||
|
expect(overview.actions.evaluate.map(action => action.aggregationRunId)).toEqual(['aggregation-2']);
|
||||||
|
expect(overview.actions.createFindings.map(action => action.evaluationRunId)).toEqual(['evaluation-2']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
|
import {
|
||||||
|
buildUnifiedResultJsonl,
|
||||||
|
queryAllUnifiedResults,
|
||||||
|
RESULT_DB_JSONL_FORMAT,
|
||||||
|
RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
|
unifiedResultJsonlFileName
|
||||||
|
} from '../../src/exports/unified-result-jsonl';
|
||||||
|
|
||||||
|
function result(id: string): UnifiedResultProjection {
|
||||||
|
return {
|
||||||
|
resultProjectionId: id,
|
||||||
|
sourceKind: 'scanner-finding',
|
||||||
|
sourceResultId: `source-${id}`,
|
||||||
|
projectId: 'project-1',
|
||||||
|
assessmentId: 'assessment-1',
|
||||||
|
collectionRunId: 'scan-1',
|
||||||
|
scanRunId: 'scan-1',
|
||||||
|
title: `Synthetic result ${id}`,
|
||||||
|
severityOriginal: 'high',
|
||||||
|
severityNormalized: 'high',
|
||||||
|
outcomeOriginal: 'fail',
|
||||||
|
outcomeNormalized: 'fail',
|
||||||
|
technicalExecutionState: 'completed',
|
||||||
|
resultType: 'compliance_check',
|
||||||
|
isIoe: false,
|
||||||
|
isDeviation: true,
|
||||||
|
lifecycleState: 'unknown',
|
||||||
|
mappingConfidence: 1,
|
||||||
|
reviewStatus: 'unreviewed',
|
||||||
|
importedAt: '2026-09-03T10:00:00.000Z',
|
||||||
|
createdAt: '2026-09-03T10:00:00.000Z',
|
||||||
|
categories: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('filtered Result DB JSONL export', () => {
|
||||||
|
it('writes an explicit manifest followed by one complete JSON object per filtered result', () => {
|
||||||
|
const text = buildUnifiedResultJsonl({
|
||||||
|
projectId: 'project-1', scanRunIds: ['scan-1'], deviation: true,
|
||||||
|
severity: ['critical', 'high'], search: 'synthetic', offset: 50, limit: 50
|
||||||
|
}, [result('result-1'), result('result-2')], '2026-09-03T12:34:56.789Z');
|
||||||
|
const lines = text.trimEnd().split('\n').map(line => JSON.parse(line));
|
||||||
|
|
||||||
|
expect(lines[0]).toEqual({
|
||||||
|
recordType: 'manifest',
|
||||||
|
format: RESULT_DB_JSONL_FORMAT,
|
||||||
|
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
|
generatedAt: '2026-09-03T12:34:56.789Z',
|
||||||
|
resultCount: 2,
|
||||||
|
scope: { projectId: 'project-1', scanRunIds: ['scan-1'] },
|
||||||
|
filters: { deviation: true, severity: ['critical', 'high'], search: 'synthetic' }
|
||||||
|
});
|
||||||
|
expect(lines.slice(1)).toEqual([
|
||||||
|
{ recordType: 'result', position: 1, result: result('result-1') },
|
||||||
|
{ recordType: 'result', position: 2, result: result('result-2') }
|
||||||
|
]);
|
||||||
|
expect(text.endsWith('\n')).toBe(true);
|
||||||
|
expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z'))
|
||||||
|
.toBe('assessor-result-db-filtered-2026-09-03T12-34-56Z.jsonl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads every filtered page with the same scope and rejects a changing result set', async () => {
|
||||||
|
const all = [result('result-1'), result('result-2'), result('result-3')];
|
||||||
|
const reader = vi.fn(async query => ({
|
||||||
|
total: all.length,
|
||||||
|
items: all.slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? all.length))
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(queryAllUnifiedResults(reader, {
|
||||||
|
projectId: 'project-1', scanRunIds: ['scan-1'], deviation: true, offset: 99, limit: 1
|
||||||
|
}, 2)).resolves.toEqual(all);
|
||||||
|
expect(reader).toHaveBeenNthCalledWith(1, {
|
||||||
|
projectId: 'project-1', scanRunIds: ['scan-1'], deviation: true, offset: 0, limit: 2
|
||||||
|
});
|
||||||
|
expect(reader).toHaveBeenNthCalledWith(2, {
|
||||||
|
projectId: 'project-1', scanRunIds: ['scan-1'], deviation: true, offset: 2, limit: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
let call = 0;
|
||||||
|
await expect(queryAllUnifiedResults(async query => {
|
||||||
|
call += 1;
|
||||||
|
return { total: call === 1 ? 2 : 3, items: [result(`result-${query.offset ?? 0}`)] };
|
||||||
|
}, { deviation: true }, 1)).rejects.toThrow('changed during export');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,29 +21,61 @@ describe('dashboard reference layout', () => {
|
||||||
expect(component).toContain('<DashboardMetricTile label="High Severity"');
|
expect(component).toContain('<DashboardMetricTile label="High Severity"');
|
||||||
expect(component).toContain('<DashboardMetricTile label="Typed Observations"');
|
expect(component).toContain('<DashboardMetricTile label="Typed Observations"');
|
||||||
expect(component).toContain('<DashboardMetricTile label="Directory Sections"');
|
expect(component).toContain('<DashboardMetricTile label="Directory Sections"');
|
||||||
|
expect(component.indexOf('label="Compliance Score"')).toBeLessThan(component.indexOf('label="Deviation"'));
|
||||||
|
expect(component.indexOf('label="Deviation"')).toBeLessThan(component.indexOf('label="High Severity"'));
|
||||||
|
expect(component.indexOf('label="High Severity"')).toBeLessThan(component.indexOf('label="Passed"'));
|
||||||
|
expect(component).toContain('<DashboardMetricTile label="Compliance Score" value={`${complianceScore}%`} emphasis />');
|
||||||
|
expect(component).toContain('<DashboardMetricTile label="Passed" value={passed.toLocaleString(\'en-US\')} tone="passed" />');
|
||||||
|
expect(component).toMatch(/label="Deviation"[^\n]*onClick=/);
|
||||||
|
expect(component).toMatch(/label="High Severity"[^\n]*onClick=/);
|
||||||
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
|
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
|
||||||
expect(styles).toMatch(/\.client-info-tile-grid\s*\{[^}]*grid-template-columns:\s*repeat\(var\(--client-info-columns\), minmax\(0, 1fr\)\);[^}]*grid-auto-flow:\s*row dense;/s);
|
expect(styles).toMatch(/\.client-info-tile-grid\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--segment-gap\);/s);
|
||||||
expect(styles).toMatch(/\.client-info-metric-grid\s*\{[^}]*display:\s*contents;/s);
|
expect(component).toContain('className="client-info-top-row"');
|
||||||
expect(styles).toMatch(/\.client-info-metric\s*\{[^}]*aspect-ratio:\s*1;[^}]*min-height:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
expect(styles).toMatch(/\.client-info-top-row\s*\{[^}]*grid-template-columns:\s*minmax\(0, 3fr\) minmax\(320px, 1fr\);/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-top-row\s*\{[^}]*align-items:\s*stretch;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-metric-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-top-row > \.client-info-metric-grid \.client-info-metric\s*\{[^}]*align-self:\s*stretch;[^}]*height:\s*100%;[^}]*min-height:\s*220px;[^}]*aspect-ratio:\s*auto;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-metric\.is-emphasis > strong\s*\{[^}]*font-size:\s*var\(--type-h1-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses exactly nine square information cards in the same dense grid', () => {
|
it('uses exactly ten progressively disclosed information cards in the same dense grid', () => {
|
||||||
expect(component).toContain('className="client-info-card-grid"');
|
expect(component).toContain('className="client-info-card-grid"');
|
||||||
expect(component.match(/<DashboardLargeCard/g)).toHaveLength(9);
|
expect(component.match(/className="client-info-card-column"/g)).toHaveLength(3);
|
||||||
['Assessment Status', 'Severity Distribution', 'Directory Overview', 'Directory Coverage', 'Identity & Privilege', 'Control Paths & Trusts', 'Certificates & PKI', 'GPO & Password Hygiene', 'Infrastructure & Resilience']
|
expect(component.match(/<DashboardLargeCard/g)).toHaveLength(8);
|
||||||
|
expect(component.match(/<DashboardDonutCard/g)).toHaveLength(2);
|
||||||
|
['Assessment Status', 'Severity Distribution', 'Directory Overview', 'Directory Coverage', 'Identity & Privilege', 'Control Paths & Trusts', 'Certificates & PKI', 'GPO & Password Hygiene', 'Infrastructure & Resilience', 'Infrastructure Details']
|
||||||
.forEach(title => expect(component).toContain(`title="${title}"`));
|
.forEach(title => expect(component).toContain(`title="${title}"`));
|
||||||
expect(styles).toMatch(/\.client-info-card-grid\s*\{[^}]*display:\s*contents;/s);
|
expect(styles).toMatch(/\.client-info-card-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/s);
|
||||||
expect(styles).toMatch(/\.client-info-card\s*\{[^}]*grid-column:\s*span 2;[^}]*grid-row:\s*span 2;[^}]*aspect-ratio:\s*1;[^}]*min-height:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
expect(styles).toMatch(/\.client-info-card-column\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*var\(--segment-gap\);/s);
|
||||||
expect(styles).toMatch(/\.client-info-card > header\s*\{[^}]*padding-bottom:\s*0;[^}]*border-bottom:\s*0;/s);
|
expect(component).not.toContain('DashboardMasonryGrid');
|
||||||
|
expect(component).not.toContain('gridRowEnd');
|
||||||
|
expect(styles).toMatch(/\.client-info-panels-layout\s*\{[^}]*grid-template-columns:\s*minmax\(0, 3fr\) minmax\(320px, 1fr\);/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-card\s*\{[^}]*min-height:\s*min-content;[^}]*align-self:\s*stretch;[^}]*padding:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-card > header\s*\{[^}]*min-height:\s*60px;[^}]*padding:\s*var\(--space-3\) var\(--space-5\);[^}]*border-bottom:\s*var\(--border-hair\) solid var\(--line\);/s);
|
||||||
|
expect(component).toContain('className="client-info-card-toggle"');
|
||||||
|
expect(component).toContain('aria-expanded={expanded}');
|
||||||
|
expect(component).toContain("<TriangleIcon direction={expanded ? 'down' : 'right'} />");
|
||||||
|
expect(styles).toMatch(/\.client-info-card-toggle \.triangle-icon\s*\{[^}]*width:\s*16px;[^}]*height:\s*16px;/s);
|
||||||
|
expect(component).toContain('initiallyExpanded = true');
|
||||||
|
expect(component.match(/initiallyExpanded=\{false\}/g)).toHaveLength(9);
|
||||||
|
expect(component).toContain('<DashboardDonutCard title="Assessment Status"');
|
||||||
|
expect(component).not.toMatch(/<DashboardDonutCard title="Assessment Status"[^>]*initiallyExpanded=\{false\}/);
|
||||||
|
expect(styles).toMatch(/\.client-info-card\.is-collapsed\s*\{[^}]*align-self:\s*start;[^}]*width:\s*100%;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-card\.is-collapsed > header\s*\{[^}]*border-bottom:\s*0;/s);
|
||||||
|
expect(component).not.toContain('className="client-info-card-summary-track"');
|
||||||
|
expect(component.match(/summary=\{\{/g)).toHaveLength(7);
|
||||||
expect(component).not.toContain('className="dashboard-status-rack"');
|
expect(component).not.toContain('className="dashboard-status-rack"');
|
||||||
expect(component).not.toContain('className="dashboard-lower-grid"');
|
expect(component).not.toContain('className="dashboard-lower-grid"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('projects normalized scanner context into reusable fact, bar and donut displays', () => {
|
it('projects normalized scanner context into reusable fact, quantity-bar and donut displays', () => {
|
||||||
expect(component).toContain('dashboardEnvironmentSummary(environment.snapshot)');
|
expect(component).toContain('dashboardEnvironmentSummary(environment.snapshot)');
|
||||||
expect(component).toContain('className="client-info-donut"');
|
expect(component).toContain('className="client-info-donut"');
|
||||||
expect(component).toContain('className="client-info-bars"');
|
expect(component).toContain('className="client-info-bars"');
|
||||||
expect(component).toContain('className="client-info-facts"');
|
expect(component).toContain('className="client-info-facts"');
|
||||||
|
expect(component).toContain('environmentSummary.controlPaths.slice(1)');
|
||||||
|
expect(component).toContain('environmentSummary.identity.slice(1)');
|
||||||
|
expect(component).toContain("const ordinalTones = ['critical', 'high', 'medium', 'accent', 'neutral']");
|
||||||
expect(component).toContain("sectionObservationCount(snapshot, 'sectionAdminGroups')");
|
expect(component).toContain("sectionObservationCount(snapshot, 'sectionAdminGroups')");
|
||||||
expect(component).toContain("observationNumber(snapshot, 'risk.anomalies', 'Anomalies score')");
|
expect(component).toContain("observationNumber(snapshot, 'risk.anomalies', 'Anomalies score')");
|
||||||
expect(styles).toContain('.client-info-donut-track { stroke: var(--donut-track); }');
|
expect(styles).toContain('.client-info-donut-track { stroke: var(--donut-track); }');
|
||||||
|
|
@ -51,6 +83,26 @@ describe('dashboard reference layout', () => {
|
||||||
expect(styles).not.toMatch(/\.client-info-card\s*\{[^}]*overflow:\s*hidden;/s);
|
expect(styles).not.toMatch(/\.client-info-card\s*\{[^}]*overflow:\s*hidden;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aligns metric labels, tracks, values and optional status text on a stable row grid', () => {
|
||||||
|
expect(component).toContain('className="client-info-bar-detail"');
|
||||||
|
expect(component).toContain("className={`client-info-bar-status${item.restricted ? '' : ' is-empty'}`}");
|
||||||
|
expect(styles).toMatch(/\.client-info-bars\s*\{[^}]*gap:\s*0;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-bar-row\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(48px, auto\) minmax\(120px, 1\.15fr\);[^}]*min-height:\s*64px;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-bar-track\s*\{[^}]*grid-column:\s*3;[^}]*grid-row:\s*1 \/ -1;[^}]*width:\s*100%;[^}]*height:\s*30px;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-bars strong\s*\{[^}]*grid-column:\s*2;[^}]*grid-row:\s*1;[^}]*justify-content:\s*flex-end;[^}]*white-space:\s*nowrap;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-bars \.client-info-bar-status\s*\{[^}]*grid-column:\s*2;[^}]*grid-row:\s*2;[^}]*min-height:\s*12px;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps infrastructure metrics and the complete resilience facts in separate cards', () => {
|
||||||
|
expect(component).toMatch(/title="Infrastructure & Resilience"[\s\S]*?<DashboardMetricBars items=\{environmentSummary\.risk\.slice\(1\)\} \/>[\s\S]*?<\/DashboardLargeCard>/);
|
||||||
|
expect(component).toContain('<DashboardLargeCard title="Infrastructure Details" initiallyExpanded={false}>');
|
||||||
|
expect(component).toContain("{ label: 'Marked findings', value:");
|
||||||
|
expect(component).toContain('...environmentSummary.resilienceFacts');
|
||||||
|
expect(component).not.toContain('className="client-info-resilience"');
|
||||||
|
expect(styles).toMatch(/\.client-info-card-details\s*\{[^}]*display:\s*grid;[^}]*align-content:\s*start;[^}]*gap:\s*0;[^}]*min-width:\s*0;[^}]*padding:\s*0 var\(--space-5\) var\(--space-3\);/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-facts > div\s*\{[^}]*grid-template-columns:\s*minmax\(110px, \.8fr\) minmax\(0, 1\.2fr\);[^}]*min-height:\s*56px;/s);
|
||||||
|
});
|
||||||
|
|
||||||
it('derives card values only from normalized environment snapshot contracts', () => {
|
it('derives card values only from normalized environment snapshot contracts', () => {
|
||||||
const observation = (property: string, label: string, value: string | number | boolean, sectionKey?: string) => ({
|
const observation = (property: string, label: string, value: string | number | boolean, sectionKey?: string) => ({
|
||||||
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1',
|
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1',
|
||||||
|
|
@ -70,6 +122,7 @@ describe('dashboard reference layout', () => {
|
||||||
} satisfies EnvironmentSnapshot;
|
} satisfies EnvironmentSnapshot;
|
||||||
const summary = dashboardEnvironmentSummary(snapshot);
|
const summary = dashboardEnvironmentSummary(snapshot);
|
||||||
expect(summary.directoryFacts[0]).toEqual({ label: 'Domain', value: 'example.local' });
|
expect(summary.directoryFacts[0]).toEqual({ label: 'Domain', value: 'example.local' });
|
||||||
|
expect(summary.directoryFacts.map(item => item.label)).not.toContain('PingCastle');
|
||||||
expect(summary.coverage.map(item => item.value)).toEqual([4, 2, 1, 3, 0]);
|
expect(summary.coverage.map(item => item.value)).toEqual([4, 2, 1, 3, 0]);
|
||||||
expect(summary.identity[0]).toMatchObject({ label: 'Admin groups', value: 1, restricted: true });
|
expect(summary.identity[0]).toMatchObject({ label: 'Admin groups', value: 1, restricted: true });
|
||||||
expect(summary.risk[1]).toMatchObject({ label: 'Anomalies', value: 42 });
|
expect(summary.risk[1]).toMatchObject({ label: 'Anomalies', value: 42 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, createElement } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DashboardDonutCard, DashboardLargeCard, DashboardMetricBars } from '../../src/features/dashboard/DashboardPanel';
|
||||||
|
|
||||||
|
describe('dashboard progressive disclosure', () => {
|
||||||
|
it('shows complete metric content initially and minimizes it to the header', () => {
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => root.render(createElement(DashboardLargeCard, {
|
||||||
|
title: 'Synthetic coverage',
|
||||||
|
meta: '10 observations',
|
||||||
|
summary: { label: 'Observed', value: 8 },
|
||||||
|
details: createElement('p', { className: 'synthetic-extra' }, 'Additional synthetic detail')
|
||||||
|
}, createElement(DashboardMetricBars, { items: [{ label: 'Visible synthetic metric', value: 8, tone: 'coverage' }] }))));
|
||||||
|
|
||||||
|
const toggle = container.querySelector('.client-info-card-toggle') as HTMLButtonElement;
|
||||||
|
expect(toggle.querySelector('.triangle-icon-down')).not.toBeNull();
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(container.querySelector('.client-info-card-details')).not.toBeNull();
|
||||||
|
expect(container.querySelector('.client-info-bar-label')?.textContent).toBe('Visible synthetic metric');
|
||||||
|
expect(container.querySelector('.synthetic-extra')?.textContent).toBe('Additional synthetic detail');
|
||||||
|
|
||||||
|
act(() => toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
expect(toggle.querySelector('.triangle-icon-right')).not.toBeNull();
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(container.querySelector('.client-info-card-content')).toBeNull();
|
||||||
|
|
||||||
|
act(() => toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
expect(container.querySelector('.client-info-card-details')).not.toBeNull();
|
||||||
|
expect((container.querySelector('.client-info-bar-track i') as HTMLElement).style.getPropertyValue('--share')).toBe('100%');
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('minimizes donut cards through the same minus control', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => root.render(createElement(DashboardDonutCard, {
|
||||||
|
title: 'Synthetic distribution',
|
||||||
|
meta: '10 records',
|
||||||
|
children: createElement('div', { className: 'synthetic-donut' }, 'Synthetic donut')
|
||||||
|
})));
|
||||||
|
|
||||||
|
const toggle = container.querySelector('.client-info-card-toggle') as HTMLButtonElement;
|
||||||
|
expect(toggle.querySelector('.triangle-icon-down')).not.toBeNull();
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(container.querySelector('.synthetic-donut')).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
expect(toggle.querySelector('.triangle-icon-right')).not.toBeNull();
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(container.querySelector('.client-info-card-content')).toBeNull();
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { DatahubHistoryOverview } from '../../src/features/project-import/DatahubHistoryOverview';
|
import { DatahubHistoryOverview } from '../../src/features/project-import/DatahubHistoryOverview';
|
||||||
|
const app = readFileSync(new URL('../../src/app/App.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
describe('Datahub client quick open', () => {
|
describe('Datahub client quick open', () => {
|
||||||
|
it('opens a selected client in Client Info by default', () => {
|
||||||
|
expect(app).toContain("if (project) { setActiveArea('dashboard'); void app.openProject(project.id, scanRunIds); }");
|
||||||
|
expect(app).not.toContain("if (project) { setActiveArea('work'); void app.openProject(project.id, scanRunIds); }");
|
||||||
|
});
|
||||||
|
|
||||||
it('offers the latest scanner-backed timeslot on each client card', () => {
|
it('offers the latest scanner-backed timeslot on each client card', () => {
|
||||||
const project = { id: 'project-a', name: 'Client A', description: '', createdAt: '2026-08-22T08:00:00.000Z', updatedAt: '2026-08-22T08:00:00.000Z', attributes: {} };
|
const project = { id: 'project-a', name: 'Client A', description: '', createdAt: '2026-08-22T08:00:00.000Z', updatedAt: '2026-08-22T08:00:00.000Z', attributes: {} };
|
||||||
const markup = renderToStaticMarkup(createElement(DatahubHistoryOverview, {
|
const markup = renderToStaticMarkup(createElement(DatahubHistoryOverview, {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ describe('Datahub assessment timeslot composition', () => {
|
||||||
expect(dataHubSource).toContain('listProjectGpoBackupStatus');
|
expect(dataHubSource).toContain('listProjectGpoBackupStatus');
|
||||||
expect(dataHubSource).toContain('projectGpoData');
|
expect(dataHubSource).toContain('projectGpoData');
|
||||||
expect(dataHubSource).not.toContain('tenant-file-gpo');
|
expect(dataHubSource).not.toContain('tenant-file-gpo');
|
||||||
|
expect(dataHubSource).toContain('reports.length + group.gpoBackups.length');
|
||||||
|
expect(dataHubSource).toContain('group.gpoBackups.map(record =>');
|
||||||
|
expect(dataHubSource).toContain('<b>GPO Backup</b>');
|
||||||
|
expect(dataHubSource).toContain('className="tenant-gpo-source-marker"');
|
||||||
|
expect(dataHubSource).toContain("onClick={() => setDetailSection('gpo-analysis')}>View</button>");
|
||||||
|
expect(dataHubSource).toContain('<PolicyAnalysisPanel projectId={form.project.id}/>');
|
||||||
|
expect(dataHubSource).toContain("globalThis.addEventListener('assessor:gpo-changed', refresh)");
|
||||||
expect(historyOverviewSource).not.toContain('listProjectGpoBackupStatus');
|
expect(historyOverviewSource).not.toContain('listProjectGpoBackupStatus');
|
||||||
expect(historyOverviewSource).not.toContain('tenant-file-gpo');
|
expect(historyOverviewSource).not.toContain('tenant-file-gpo');
|
||||||
expect(historyOverviewSource).toContain('groupProjectImportFiles(');
|
expect(historyOverviewSource).toContain('groupProjectImportFiles(');
|
||||||
|
|
@ -26,10 +33,16 @@ describe('Datahub assessment timeslot composition', () => {
|
||||||
expect(historyOverviewSource).toContain('DonutMetricCard className="datahub-reference-knob" label="Error"');
|
expect(historyOverviewSource).toContain('DonutMetricCard className="datahub-reference-knob" label="Error"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the detailed GPO processing status available in the selected timeslot', () => {
|
it('keeps detailed status management and supports folder as well as ZIP selection', () => {
|
||||||
expect(gpoImportSource).toContain('aria-label="GPO processing status"');
|
expect(gpoImportSource).toContain('aria-label="GPO processing status"');
|
||||||
expect(gpoImportSource).toContain('GPO Processing');
|
expect(gpoImportSource).toContain('GPO Processing');
|
||||||
expect(gpoImportSource).toContain('1 tenant GPO backup');
|
expect(gpoImportSource).toContain('1 tenant GPO backup');
|
||||||
expect(gpoImportSource).toContain('GPO{record.gpoCount === 1');
|
expect(gpoImportSource).toContain('GPO{record.gpoCount === 1');
|
||||||
|
expect(gpoImportSource).toContain("input.setAttribute('webkitdirectory', '')");
|
||||||
|
expect(gpoImportSource).toContain('accept=".zip,application/zip"');
|
||||||
|
expect(gpoImportSource).toContain('submitFolder(event.target.files)');
|
||||||
|
expect(gpoImportSource).toContain('submitArchive(event.target.files[0])');
|
||||||
|
expect(dataHubSource).toContain('label="Import GPO Folder" archiveLabel="Import GPO ZIP"');
|
||||||
|
expect(dataHubSource).toContain("record.state === 'dead-letter'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,14 @@ describe('Mandant detail reference', () => {
|
||||||
expect(styles).toMatch(/\.tenant-detail-page \.tenant-navigation-open\s*\{[^}]*grid-column:\s*2;[^}]*justify-self:\s*end;/s);
|
expect(styles).toMatch(/\.tenant-detail-page \.tenant-navigation-open\s*\{[^}]*grid-column:\s*2;[^}]*justify-self:\s*end;/s);
|
||||||
expect(dataHub).toContain('>Delete timeslot</button>');
|
expect(dataHub).toContain('>Delete timeslot</button>');
|
||||||
expect(dataHub).toContain('label="Import Scan"');
|
expect(dataHub).toContain('label="Import Scan"');
|
||||||
expect(dataHub).toContain('label="Import GPO Backup Folder (Zip)"');
|
expect(dataHub).toContain('label="Import GPO Folder"');
|
||||||
|
expect(dataHub).toContain('archiveLabel="Import GPO ZIP"');
|
||||||
expect(dataHub).toContain('targetAssessmentId={group.assessmentIds[0]}');
|
expect(dataHub).toContain('targetAssessmentId={group.assessmentIds[0]}');
|
||||||
expect(dataHub).toContain('targetAssessmentId={report.assessmentIds[0]}');
|
expect(dataHub).toContain('targetAssessmentId={report.assessmentIds[0]}');
|
||||||
expect(dataHub).toContain('assessmentId={group.assessmentIds[0]}');
|
expect(dataHub).toContain('assessmentId={group.assessmentIds[0]}');
|
||||||
expect(dataHub).toContain('showStatus={false}');
|
expect(dataHub).toContain('showStatus={false}');
|
||||||
expect(gpoImport).toContain('<button className="btn primary"');
|
expect(gpoImport).toContain('<button className="btn primary"');
|
||||||
|
expect(gpoImport).toContain('accept=".zip,application/zip"');
|
||||||
expect(dataHub).toContain('<span>Status</span><span>Actions</span>');
|
expect(dataHub).toContain('<span>Status</span><span>Actions</span>');
|
||||||
expect(dataHub).toContain("processing.state === 'update' ? 'reimport-recommended' : processing.state === 'error' ? 'processing-error' : readiness");
|
expect(dataHub).toContain("processing.state === 'update' ? 'reimport-recommended' : processing.state === 'error' ? 'processing-error' : readiness");
|
||||||
expect(dataHub).toContain('disabled={!viewable}');
|
expect(dataHub).toContain('disabled={!viewable}');
|
||||||
|
|
@ -57,7 +59,8 @@ describe('Mandant detail reference', () => {
|
||||||
expect(dataHub).not.toContain('Scan report 01');
|
expect(dataHub).not.toContain('Scan report 01');
|
||||||
expect(dataHub).not.toContain('23.07.2026');
|
expect(dataHub).not.toContain('23.07.2026');
|
||||||
expect(dataHub).not.toContain('24.07.2026');
|
expect(dataHub).not.toContain('24.07.2026');
|
||||||
expect(dataHub).not.toContain('PolicyAnalysisPanel');
|
expect(dataHub).toContain("type TenantDetailSection = 'client-information' | 'gpo-analysis'");
|
||||||
|
expect(dataHub).toContain('<PolicyAnalysisPanel projectId={form.project.id}/>');
|
||||||
expect(gpoImport).toContain('showStatus = true');
|
expect(gpoImport).toContain('showStatus = true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
|
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||||
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||||
|
import { appendPolicyObservationsToDataset } from '../../src/features/project-import/policy-observations-to-dataset';
|
||||||
|
|
||||||
|
describe('GPO Work projection', () => {
|
||||||
|
it('creates a selectable Work sheet without presenting observations as findings', () => {
|
||||||
|
const result = {
|
||||||
|
resultProjectionId: 'policy-observation:one', sourceKind: 'policy-observation', sourceResultId: 'one',
|
||||||
|
projectId: 'project-one', assessmentId: 'assessment-one', collectionRunId: 'processing-one',
|
||||||
|
sourceTool: 'group-policy', title: 'System Access · MinimumPasswordLength',
|
||||||
|
severityOriginal: null, severityNormalized: 'unknown', outcomeOriginal: 'configured', outcomeNormalized: 'warning',
|
||||||
|
technicalExecutionState: 'completed', resultType: 'informational', isIoe: false, isDeviation: false,
|
||||||
|
lifecycleState: 'unknown', mappingConfidence: 0, reviewStatus: 'open', importedAt: '2026-09-03T12:00:00.000Z',
|
||||||
|
createdAt: '2026-09-03T12:00:00.000Z', categories: [], policyScope: 'computer',
|
||||||
|
policyDeliveryMechanism: 'security-template', policyState: 'configured', policyIdentityStatus: 'unresolved',
|
||||||
|
policyIdentityKey: 'security-template:System Access:MinimumPasswordLength',
|
||||||
|
policyValues: [{ kind: 'scalar', raw: '14' }], policyDiagnosticCodes: ['identity-review-required'],
|
||||||
|
policyBackupInstanceId: 'backup-one', sourceLocator: { objectPath: 'GPO/Machine/GptTmpl.inf#lines=3' }
|
||||||
|
} satisfies UnifiedResultProjection;
|
||||||
|
|
||||||
|
const dataset = appendPolicyObservationsToDataset(null, 'Synthetic client', [result]);
|
||||||
|
|
||||||
|
expect(dataset?.scanTool).toBe('Group Policy');
|
||||||
|
expect(dataset?.sheets).toHaveLength(1);
|
||||||
|
expect(dataset?.sheets[0]).toMatchObject({ name: 'Group Policy · Observations', toolId: 'group-policy' });
|
||||||
|
expect(dataset?.sheets[0].analysis.headers).toEqual(expect.arrayContaining([
|
||||||
|
'Title', 'Status', 'Configured Value', 'Identity Status', 'Source Path'
|
||||||
|
]));
|
||||||
|
expect(dataset?.sheets[0].analysis.records[0]).toMatchObject({
|
||||||
|
title: 'System Access · MinimumPasswordLength'
|
||||||
|
});
|
||||||
|
expect(dataset?.sheets[0].rows[1]).toContain('Investigate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds Group Policy to the project-wide Work source facet and keeps a dedicated sheet', () => {
|
||||||
|
const result = {
|
||||||
|
resultProjectionId: 'policy-observation:two', sourceKind: 'policy-observation', sourceResultId: 'two',
|
||||||
|
projectId: 'project-one', assessmentId: 'assessment-one', collectionRunId: 'processing-one',
|
||||||
|
sourceTool: 'group-policy', title: 'Privilege Rights · SeSecurityPrivilege',
|
||||||
|
severityOriginal: null, severityNormalized: 'unknown', outcomeOriginal: 'configured', outcomeNormalized: 'warning',
|
||||||
|
technicalExecutionState: 'completed', resultType: 'informational', isIoe: false, isDeviation: false,
|
||||||
|
lifecycleState: 'unknown', mappingConfidence: 0, reviewStatus: 'open', importedAt: '2026-09-03T12:00:00.000Z',
|
||||||
|
createdAt: '2026-09-03T12:00:00.000Z', categories: [], policyScope: 'computer',
|
||||||
|
policyDeliveryMechanism: 'security-template', policyState: 'configured', policyIdentityStatus: 'unresolved',
|
||||||
|
policyIdentityKey: 'security-template:Privilege Rights:SeSecurityPrivilege', policyValues: [{ kind: 'principal-set', principals: ['S-1-5-19'] }],
|
||||||
|
policyDiagnosticCodes: [], policyBackupInstanceId: 'backup-two'
|
||||||
|
} satisfies UnifiedResultProjection;
|
||||||
|
const aggregate = makeSheet('Synthetic · All Records', [
|
||||||
|
['Source: File', 'Source: Sheet', 'Source: Scan', 'Title', 'Status'],
|
||||||
|
['scanner.csv', 'Findings', 'Scanner · 2026-09-03', 'Scanner finding', 'Failed']
|
||||||
|
], '', 'Synthetic client', { isProjectSheet: true });
|
||||||
|
const scannerDataset: ImportedDataset = {
|
||||||
|
fileName: 'Synthetic', fileSize: 10, lastModified: 0, format: 'Project', rawText: '',
|
||||||
|
sheets: [aggregate], profileId: 'generic', scanTool: 'Scanner'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataset = appendPolicyObservationsToDataset(scannerDataset, 'Synthetic client', [result]);
|
||||||
|
const sourceIndex = dataset!.sheets[0].analysis.headers.indexOf('Source: Scan');
|
||||||
|
|
||||||
|
expect(dataset?.sheets.map(sheet => sheet.name)).toEqual([
|
||||||
|
'Synthetic · All Records', 'Group Policy · Observations'
|
||||||
|
]);
|
||||||
|
expect(dataset?.sheets[0].rows.slice(1).map(row => row[sourceIndex])).toEqual([
|
||||||
|
'Scanner · 2026-09-03', 'Group Policy · 2026-09-03T12:00:00.000Z'
|
||||||
|
]);
|
||||||
|
expect(dataset?.sheets[0].analysis.records.map(record => record.title)).toContain('Privilege Rights · SeSecurityPrivilege');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -125,6 +125,29 @@ describe('LinkedResults', () => {
|
||||||
expect(styles).toMatch(/\.linked-results-heading\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
expect(styles).toMatch(/\.linked-results-heading\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeigt Maester-Ausfuehrungsfehler als lesbare Diagnose mit einklappbarem Rohtext', () => {
|
||||||
|
const raw = 'Synthetic raw Maester diagnostic';
|
||||||
|
const html = renderToStaticMarkup(createElement(LinkedResults, {
|
||||||
|
sheets: [],
|
||||||
|
sections: [{
|
||||||
|
title: 'Test result',
|
||||||
|
value: raw,
|
||||||
|
diagnostic: {
|
||||||
|
message: 'Synthetic permission denied.', code: 'Authorization_RequestDenied',
|
||||||
|
method: 'GET', endpoint: 'https://graph.microsoft.com/v1.0/example', httpStatus: '403 Forbidden',
|
||||||
|
source: 'Invoke-MgGraphRequestCache.ps1 · line 49', requestId: 'request-synthetic', raw
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html).toContain('class="maester-error__summary"');
|
||||||
|
expect(html).toContain('Synthetic permission denied.');
|
||||||
|
expect(html).toContain('403 Forbidden');
|
||||||
|
expect(html).toContain('Authorization_RequestDenied');
|
||||||
|
expect(html).toContain('<summary>Technical details</summary>');
|
||||||
|
expect(html).toContain('Synthetic raw Maester diagnostic');
|
||||||
|
});
|
||||||
|
|
||||||
it('benennt den Ergebnisblock eines Blattes nach dem Blatt', () => {
|
it('benennt den Ergebnisblock eines Blattes nach dem Blatt', () => {
|
||||||
const html = renderToStaticMarkup(createElement(LinkedResults, { sheets: [sheet] }));
|
const html = renderToStaticMarkup(createElement(LinkedResults, { sheets: [sheet] }));
|
||||||
expect(html).toContain('>Privileged Users with Weak Password Policy</h4>');
|
expect(html).toContain('>Privileged Users with Weak Password Policy</h4>');
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ const overview = {
|
||||||
configuredMatches: 0, confirmedDeviations: 0, notApplicable: 0, policyFindings: 0,
|
configuredMatches: 0, confirmedDeviations: 0, notApplicable: 0, policyFindings: 0,
|
||||||
reviewItems: 1, conflicts: 0, unresolvedIdentities: 0, catalogGaps: 1, parserGaps: 0, partialRuns: 1
|
reviewItems: 1, conflicts: 0, unresolvedIdentities: 0, catalogGaps: 1, parserGaps: 0, partialRuns: 1
|
||||||
},
|
},
|
||||||
|
current: {
|
||||||
|
version: 1,
|
||||||
|
summary: {
|
||||||
|
identityAnalysisRuns: 0, aggregationRuns: 0, evaluationRuns: 0, findingRuns: 0,
|
||||||
|
configuredMatches: 0, confirmedDeviations: 0, notApplicable: 0, policyFindings: 0,
|
||||||
|
reviewItems: 0, conflicts: 0, unresolvedIdentities: 0, catalogGaps: 0, parserGaps: 0, partialRuns: 0
|
||||||
|
},
|
||||||
|
evaluationRunIds: [], findingRunIds: [], reviewItems: []
|
||||||
|
},
|
||||||
profiles: [{ version: 1, id: 'profile-1', name: 'Synthetic Windows', revision: 1, active: true, contextCount: 1 }],
|
profiles: [{ version: 1, id: 'profile-1', name: 'Synthetic Windows', revision: 1, active: true, contextCount: 1 }],
|
||||||
evaluations: [], reviewItems: [],
|
evaluations: [], reviewItems: [],
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,21 @@ const overview: PolicyAnalysisOverview = {
|
||||||
configuredMatches: 0, confirmedDeviations: 1, notApplicable: 0, policyFindings: 0,
|
configuredMatches: 0, confirmedDeviations: 1, notApplicable: 0, policyFindings: 0,
|
||||||
reviewItems: 2, conflicts: 1, unresolvedIdentities: 0, catalogGaps: 1, parserGaps: 0, partialRuns: 1
|
reviewItems: 2, conflicts: 1, unresolvedIdentities: 0, catalogGaps: 1, parserGaps: 0, partialRuns: 1
|
||||||
},
|
},
|
||||||
|
current: {
|
||||||
|
version: 1,
|
||||||
|
summary: {
|
||||||
|
identityAnalysisRuns: 1, aggregationRuns: 1, evaluationRuns: 1, findingRuns: 1,
|
||||||
|
configuredMatches: 0, confirmedDeviations: 1, notApplicable: 0, policyFindings: 0,
|
||||||
|
reviewItems: 2, conflicts: 1, unresolvedIdentities: 0, catalogGaps: 1, parserGaps: 0, partialRuns: 1
|
||||||
|
},
|
||||||
|
evaluationRunIds: ['evaluation-1'], findingRunIds: ['finding-run-1'], reviewItems: [{
|
||||||
|
version: 1, id: 'review-1', category: 'conflict', source: 'snapshot-aggregation', sourceRunId: 'aggregation-1',
|
||||||
|
identityId: 'technical-identity-1', codes: ['policy-snapshot-customer-conflict'], observationCount: 2, backupCount: 2
|
||||||
|
}, {
|
||||||
|
version: 1, id: 'review-2', category: 'catalog-gap', source: 'policy-evaluation', sourceRunId: 'evaluation-1',
|
||||||
|
codes: ['policy-evaluation-baseline-missing'], observationCount: 1, backupCount: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
actions: { version: 1, reprocess: [], aggregate: [], evaluate: [], createFindings: [] },
|
actions: { version: 1, reprocess: [], aggregate: [], evaluate: [], createFindings: [] },
|
||||||
profiles: [{ version: 1, id: 'profile-1', name: 'Synthetic Windows', revision: 1, active: true, contextCount: 1 }],
|
profiles: [{ version: 1, id: 'profile-1', name: 'Synthetic Windows', revision: 1, active: true, contextCount: 1 }],
|
||||||
evaluations: [{
|
evaluations: [{
|
||||||
|
|
@ -33,9 +48,10 @@ describe('PolicyAnalysisPanelView', () => {
|
||||||
const html = renderToStaticMarkup(<PolicyAnalysisPanelView overview={overview}/>);
|
const html = renderToStaticMarkup(<PolicyAnalysisPanelView overview={overview}/>);
|
||||||
|
|
||||||
expect(html).toContain('Effective application is not verified without RSoP or GP Results');
|
expect(html).toContain('Effective application is not verified without RSoP or GP Results');
|
||||||
expect(html).toContain('persisted run history');
|
expect(html).toContain('current active snapshot chains');
|
||||||
|
expect(html).toContain('Evaluation history');
|
||||||
expect(html).toContain('Confirmed policy findings');
|
expect(html).toContain('Confirmed policy findings');
|
||||||
expect(html).toContain('Review queue');
|
expect(html).toContain('Current review queue');
|
||||||
expect(html).toContain('Conflict');
|
expect(html).toContain('Conflict');
|
||||||
expect(html).toContain('Catalog gap');
|
expect(html).toContain('Catalog gap');
|
||||||
expect(html).toContain('policy-evaluation-baseline-missing');
|
expect(html).toContain('policy-evaluation-baseline-missing');
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,23 @@ describe('resultSectionsFor', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strukturiert einen Maester-Graphfehler, ohne den Originaltext zu verlieren', () => {
|
||||||
|
const raw = 'Error. An error occurred while running the test. Invoke-MgGraphRequest: /synthetic/Invoke-MgGraphRequestCache.ps1:49 | GET https://graph.microsoft.com/v1.0/example HTTP/2.0 403 Forbidden Cache-Control: no-cache | {"error":{"code":"Authorization_RequestDenied","message":"Synthetic permission denied.","innerError":{"date":"2026-09-03T10:00:00Z","request-id":"request-synthetic","client-request-id":"client-synthetic"}}}';
|
||||||
|
const [section] = resultSectionsFor(maesterRecord([['Result', raw]]), 'maester');
|
||||||
|
|
||||||
|
expect(section.diagnostic).toMatchObject({
|
||||||
|
message: 'Synthetic permission denied.',
|
||||||
|
code: 'Authorization_RequestDenied',
|
||||||
|
method: 'GET',
|
||||||
|
endpoint: 'https://graph.microsoft.com/v1.0/example',
|
||||||
|
httpStatus: '403 Forbidden',
|
||||||
|
source: 'Invoke-MgGraphRequestCache.ps1 · line 49',
|
||||||
|
requestId: 'request-synthetic',
|
||||||
|
clientRequestId: 'client-synthetic'
|
||||||
|
});
|
||||||
|
expect(section.diagnostic?.raw).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
it('liefert nichts fuer ein Toolprofil ohne Ergebnisabschnitte (z. B. Purple Knight)', () => {
|
it('liefert nichts fuer ein Toolprofil ohne Ergebnisabschnitte (z. B. Purple Knight)', () => {
|
||||||
const record = maesterRecord([['Name', 'Some finding'], ['Result', 'IOE Found']]);
|
const record = maesterRecord([['Name', 'Some finding'], ['Result', 'IOE Found']]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,32 @@ describe('Result DB timeslot scope', () => {
|
||||||
const postgresSource = readFileSync('server/postgres/postgres-assessor-repository.ts', 'utf8');
|
const postgresSource = readFileSync('server/postgres/postgres-assessor-repository.ts', 'utf8');
|
||||||
|
|
||||||
expect(appSource).toContain('scanRunIds={app.activeScanRunIds}');
|
expect(appSource).toContain('scanRunIds={app.activeScanRunIds}');
|
||||||
|
expect(appSource).toContain('assessmentIds={app.activeAssessmentIds}');
|
||||||
expect(browserSource).toContain("backendStatus !== 'connected' || !projectId || !scanRunIds?.length");
|
expect(browserSource).toContain("backendStatus !== 'connected' || !projectId || !scanRunIds?.length");
|
||||||
expect(browserSource).toContain('<h3>No timeslot selected</h3>');
|
expect(browserSource).toContain('<h3>No timeslot selected</h3>');
|
||||||
expect(browserSource).toMatch(/scanRunIds\r?\n };/);
|
expect(browserSource).toContain('assessmentIds');
|
||||||
expect(browserSource).toContain('queryUnifiedResultFacets(scopedQuery)');
|
expect(browserSource).toContain('queryUnifiedResultFacets(scopedQuery)');
|
||||||
expect(browserSource).toContain('queryUnifiedResults({ ...scopedQuery, offset: 0, limit })');
|
expect(browserSource).toContain('queryUnifiedResults({ ...scopedQuery, offset: 0, limit })');
|
||||||
expect(browserSource).toContain('while (loadAll && items.length < firstPage.total)');
|
expect(browserSource).toContain('while (loadAll && items.length < firstPage.total)');
|
||||||
expect(browserSource).toContain('limit: MAX_QUERY_PAGE_SIZE');
|
expect(browserSource).toContain('limit: MAX_QUERY_PAGE_SIZE');
|
||||||
expect(browserSource).toContain('>Load more</button>');
|
expect(browserSource).toContain('>Load more</button>');
|
||||||
expect(browserSource).toContain('>Load all</button>');
|
expect(browserSource).toContain('>Load all</button>');
|
||||||
|
expect(browserSource).toContain('DEFAULT_RESULT_DB_FILTERS: UnifiedResultQuery = { deviation: true }');
|
||||||
|
expect(browserSource).toContain('onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })}');
|
||||||
|
expect(browserSource).toContain("'Export JSONL'");
|
||||||
|
expect(browserSource).toContain('queryAllUnifiedResults');
|
||||||
|
expect(browserSource).toContain('buildUnifiedResultJsonl(scopedQuery, results, generatedAt)');
|
||||||
expect(browserSource).toContain("item.sourceKind === 'environment-observation'");
|
expect(browserSource).toContain("item.sourceKind === 'environment-observation'");
|
||||||
|
expect(browserSource).toContain("item.sourceKind === 'policy-observation'");
|
||||||
|
expect(browserSource).toContain('item.policyValues');
|
||||||
expect(browserSource).toContain('item.environmentNormalizedValue');
|
expect(browserSource).toContain('item.environmentNormalizedValue');
|
||||||
expect(browserSource).toContain('item.environmentOriginalValue');
|
expect(browserSource).toContain('item.environmentOriginalValue');
|
||||||
expect(domainSource).toContain("query.scanRunIds.includes(item.scanRunId)");
|
expect(domainSource).toContain("query.scanRunIds.includes(item.scanRunId)");
|
||||||
expect(domainSource).toContain("sourceKind: 'environment-observation'");
|
expect(domainSource).toContain("sourceKind: 'environment-observation'");
|
||||||
|
expect(domainSource).toContain("sourceKind: 'policy-observation'");
|
||||||
expect(domainSource).toContain('environmentProperty: observation.property');
|
expect(domainSource).toContain('environmentProperty: observation.property');
|
||||||
expect(serverSource).toContain("scanRunIds: many('scanRunIds')");
|
expect(serverSource).toContain("scanRunIds: many('scanRunIds')");
|
||||||
|
expect(serverSource).toContain("assessmentIds: many('assessmentIds')");
|
||||||
expect(serverSource).toContain("environmentProperty: many('environmentProperty')");
|
expect(serverSource).toContain("environmentProperty: many('environmentProperty')");
|
||||||
expect(postgresSource).toContain("r.id = ANY(?::text[])");
|
expect(postgresSource).toContain("r.id = ANY(?::text[])");
|
||||||
expect(postgresSource).toContain('FROM assessor_environment_observations o');
|
expect(postgresSource).toContain('FROM assessor_environment_observations o');
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,14 @@ describe('tileBadges', () => {
|
||||||
.toEqual(['CIS 2', 'CIS 2.2']);
|
.toEqual(['CIS 2', 'CIS 2.2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ordnet abgeleitete Kategorien vor Severity, Weight und Score ein', () => {
|
||||||
|
const cisRecord: FindingRecord = { ...record, title: '2.2.2 Ensure a synthetic user right' };
|
||||||
|
|
||||||
|
expect(tileBadges(cisRecord, 'cis-cat').map(badge => badge.text)).toEqual([
|
||||||
|
'IOE Found', 'Entra ID', 'CIS 2', 'CIS 2.2', 'Low', 'Weight: 2', 'Score: 86'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('verdichtet ueberzaehlige Graphbadges zu einem sichtbaren Zaehler', () => {
|
it('verdichtet ueberzaehlige Graphbadges zu einem sichtbaren Zaehler', () => {
|
||||||
const badges = [
|
const badges = [
|
||||||
{ label: 'Status', value: 'Fail', text: 'Fail' },
|
{ label: 'Status', value: 'Fail', text: 'Fail' },
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ describe('binding segment and tile grammar', () => {
|
||||||
'--segment-inset: var(--space-3);', '--segment-gap: var(--space-3);',
|
'--segment-inset: var(--space-3);', '--segment-gap: var(--space-3);',
|
||||||
'--tile-surface:', '--tile-surface-hover:', '--tile-border:',
|
'--tile-surface:', '--tile-surface-hover:', '--tile-border:',
|
||||||
'--tile-selected-border: var(--marking-color);', '--tile-radius: var(--radius-sm);',
|
'--tile-selected-border: var(--marking-color);', '--tile-radius: var(--radius-sm);',
|
||||||
'--tile-compact-width: 186px;', '--tile-content-width: 320px;',
|
'--tile-compact-width: 186px;', '--tile-content-width: 358px;',
|
||||||
|
'--tile-content-min-height: 223px;',
|
||||||
'--tile-card-shadow:'
|
'--tile-card-shadow:'
|
||||||
]) expect(tokens).toContain(token);
|
]) expect(tokens).toContain(token);
|
||||||
|
|
||||||
|
|
@ -58,9 +59,14 @@ describe('binding segment and tile grammar', () => {
|
||||||
expect(layout).toMatch(/\.tile-view-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
|
expect(layout).toMatch(/\.tile-view-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
|
||||||
expect(legacy).toMatch(/\.tile-grid\s*\{[^}]*justify-content:\s*start;[^}]*border-radius:\s*var\(--segment-radius\);/s);
|
expect(legacy).toMatch(/\.tile-grid\s*\{[^}]*justify-content:\s*start;[^}]*border-radius:\s*var\(--segment-radius\);/s);
|
||||||
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
||||||
expect(viewer).toContain('gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))`');
|
expect(viewer).toContain('const TILE_MIN_WIDTH = 358;');
|
||||||
|
expect(viewer).toContain('const TILE_MIN_HEIGHT = 223;');
|
||||||
|
expect(viewer).toContain('gridTemplateColumns: `repeat(${tileColumns}, var(--tile-content-width))`');
|
||||||
expect(viewer).toContain('const TILE_GAP = 12;');
|
expect(viewer).toContain('const TILE_GAP = 12;');
|
||||||
expect(viewer).toContain('const TILE_ROW_HEIGHT = 172;');
|
expect(viewer).toContain('const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;');
|
||||||
|
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*min-height:\s*var\(--tile-content-min-height\);/s);
|
||||||
|
expect(legacy).toMatch(/\.tile-grid > \.data-tile \.data-tile-badges\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*max-content max-content;/s);
|
||||||
|
expect(legacy).toMatch(/\[data-badge-role="status"\],[\s\S]*\[data-badge-role="category"\],[\s\S]*\[data-badge-role="score"\]\s*\{[^}]*grid-column:\s*1 \/ -1;/s);
|
||||||
|
|
||||||
expect(policy).toContain('grid-template-columns: repeat(auto-fill, var(--tile-compact-width));');
|
expect(policy).toContain('grid-template-columns: repeat(auto-fill, var(--tile-compact-width));');
|
||||||
expect(pathfinder).toMatch(/\.finding-path-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
expect(pathfinder).toMatch(/\.finding-path-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,10 @@ assert.match(gpoFolderTransport, /maximumZipEntries/, 'GPO folder transport must
|
||||||
assert.match(gpoFolderTransport, /compressionMethod|setUint16\(offset \+ 8, 0/, 'GPO folder transport must use a bounded non-compressing transport envelope');
|
assert.match(gpoFolderTransport, /compressionMethod|setUint16\(offset \+ 8, 0/, 'GPO folder transport must use a bounded non-compressing transport envelope');
|
||||||
const gpoFolderUi = read(join('src', 'features', 'project-import', 'GpoFolderImport.tsx'));
|
const gpoFolderUi = read(join('src', 'features', 'project-import', 'GpoFolderImport.tsx'));
|
||||||
assert.match(gpoFolderUi, /webkitdirectory/, 'the Datahub GPO import must select a complete browser folder');
|
assert.match(gpoFolderUi, /webkitdirectory/, 'the Datahub GPO import must select a complete browser folder');
|
||||||
assert.match(gpoFolderUi, /submitGpoBackup\(projectId, envelope, assessmentId\)/, 'the GPO folder envelope must cross the project-and-timeslot-bound API');const gpoZipInventory = read(join('server', 'gpo', 'zip-inventory.ts'));
|
assert.match(gpoFolderUi, /announceSubmission\(envelope,/, 'the GPO folder envelope must enter the shared submission boundary');
|
||||||
|
assert.match(gpoFolderUi, /submitGpoBackup\(projectId, file, assessmentId\)/, 'folder envelopes and uploaded ZIPs must cross the project-and-timeslot-bound API');
|
||||||
|
assert.match(gpoFolderUi, /accept="\.zip,application\/zip"/, 'the Datahub GPO import must also accept an existing ZIP package');
|
||||||
|
const gpoZipInventory = read(join('server', 'gpo', 'zip-inventory.ts'));
|
||||||
assert.match(gpoZipInventory, /zip-entry-crc-mismatch/, 'GPO ZIP inventory must verify actual entry CRC values');
|
assert.match(gpoZipInventory, /zip-entry-crc-mismatch/, 'GPO ZIP inventory must verify actual entry CRC values');
|
||||||
assert.match(gpoZipInventory, /zip-nested-archive-rejected/, 'GPO ZIP inventory must reject nested archives');
|
assert.match(gpoZipInventory, /zip-nested-archive-rejected/, 'GPO ZIP inventory must reject nested archives');
|
||||||
assert.match(gpoZipInventory, /zip-link-rejected/, 'GPO ZIP inventory must reject symbolic links');
|
assert.match(gpoZipInventory, /zip-link-rejected/, 'GPO ZIP inventory must reject symbolic links');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
loadPolicyEvaluationRuleCatalog,
|
||||||
|
MAXIMUM_POLICY_RULE_CATALOG_BYTES
|
||||||
|
} from '../../server/gpo/policy-evaluation-rule-catalog-loader';
|
||||||
|
import {
|
||||||
|
POLICY_EVALUATION_RULE_CATALOG,
|
||||||
|
RELEASED_POLICY_EVALUATION_RULE_CATALOG,
|
||||||
|
type PolicyEvaluationRuleCatalog
|
||||||
|
} from '../../src/domain/policy-evaluation-rules';
|
||||||
|
|
||||||
|
const directories: string[] = [];
|
||||||
|
|
||||||
|
function syntheticCatalog(): PolicyEvaluationRuleCatalog {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
id: 'synthetic-curated-policy-rules-1',
|
||||||
|
catalog: POLICY_EVALUATION_RULE_CATALOG,
|
||||||
|
status: 'released',
|
||||||
|
rules: [{
|
||||||
|
version: 1,
|
||||||
|
id: 'synthetic-rule-1',
|
||||||
|
identityId: 'registry:computer:hklm:software\\policies\\synthetic:value',
|
||||||
|
deliveryMechanism: 'registry-policy',
|
||||||
|
contexts: [{
|
||||||
|
version: 1,
|
||||||
|
productFamily: 'Synthetic Windows',
|
||||||
|
productVersion: '1',
|
||||||
|
roles: ['Member Server'],
|
||||||
|
scopes: ['computer']
|
||||||
|
}],
|
||||||
|
baselineDisposition: 'explicit-recommendation',
|
||||||
|
comparator: { kind: 'equality' },
|
||||||
|
riskRule: { version: 1, id: 'synthetic-risk-1', severity: 'high' },
|
||||||
|
dependencies: []
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function catalogFile(content: string): Promise<{ path: string; sha256: string }> {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'assessor-policy-catalog-'));
|
||||||
|
directories.push(directory);
|
||||||
|
const path = join(directory, 'catalog.json');
|
||||||
|
await writeFile(path, content, 'utf8');
|
||||||
|
return { path, sha256: createHash('sha256').update(content, 'utf8').digest('hex') };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('policy evaluation rule catalog loader', () => {
|
||||||
|
it('keeps the safe empty released catalog when no operator file is configured', async () => {
|
||||||
|
const loaded = await loadPolicyEvaluationRuleCatalog();
|
||||||
|
expect(loaded).toEqual(RELEASED_POLICY_EVALUATION_RULE_CATALOG);
|
||||||
|
expect(loaded).not.toBe(RELEASED_POLICY_EVALUATION_RULE_CATALOG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a pinned, structurally and semantically valid curated catalog', async () => {
|
||||||
|
const expected = syntheticCatalog();
|
||||||
|
const source = `\uFEFF${JSON.stringify(expected)}`;
|
||||||
|
const file = await catalogFile(source);
|
||||||
|
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: file.path,
|
||||||
|
expectedSha256: file.sha256
|
||||||
|
})).resolves.toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for incomplete configuration, hash drift, malformed structure, and oversize input', async () => {
|
||||||
|
const valid = await catalogFile(JSON.stringify(syntheticCatalog()));
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({ path: valid.path }))
|
||||||
|
.rejects.toThrow('gemeinsam konfiguriert');
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: valid.path,
|
||||||
|
expectedSha256: '0'.repeat(64)
|
||||||
|
})).rejects.toThrow('SHA-256');
|
||||||
|
|
||||||
|
const malformed = await catalogFile(JSON.stringify({ version: 1, rules: [null] }));
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: malformed.path,
|
||||||
|
expectedSha256: malformed.sha256
|
||||||
|
})).rejects.toThrow('keine gueltige Struktur');
|
||||||
|
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: valid.path,
|
||||||
|
expectedSha256: valid.sha256,
|
||||||
|
maximumBytes: 1
|
||||||
|
})).rejects.toThrow('Groessengrenze');
|
||||||
|
await expect(loadPolicyEvaluationRuleCatalog({
|
||||||
|
path: valid.path,
|
||||||
|
expectedSha256: valid.sha256,
|
||||||
|
maximumBytes: MAXIMUM_POLICY_RULE_CATALOG_BYTES + 1
|
||||||
|
})).rejects.toThrow('Groessengrenze');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue