Compare commits

..

3 Commits

15 changed files with 781 additions and 201 deletions

View File

@ -1160,6 +1160,7 @@ Umgesetzter Stand vom 2026-08-23:
- Typisierte Beobachtungen enthalten Scanner-/Berichtsmetadaten, Risiko-Scores, stabile zentrale AD-Schluessel fuer Domaene, Forest, Function Level, Schema, DC-Anzahl und Recycle Bin sowie generische, quellentreue Werte fuer jede weitere Tabellenzelle. LAPS, Backup, WEF, krbtgt und ausdruecklich fehlende Entra-Konfiguration werden als eigene Fakten abgebildet.
- `ScannerReportSection` und `EnvironmentObservation` erweitern das kanonische Modell additiv. Migration 018, Local-/PostgreSQL-Repositories, Projekt-Export und eine read-only Environment-Snapshot-API speichern und liefern denselben Vertrag.
- Die Unified Result DB projiziert jede typisierte EnvironmentObservation als eigenen Result-Typ `environment-observation` neben Scanner-Findings und Policy-Evaluationen. Original- und Normalwert, Property, Subject, Erfassungszustand, Abschnitt, Locator sowie Import-, Profil- und Mappingprovenienz bleiben direkt ueber Listen-, Facetten- und Einzelresultat-API abrufbar. Environment-spezifische Facetten filtern Zustand, Property und Subject; Finding-Achsen wie Severity, IOE, Abweichung und Review werden fuer diese Zeilen nicht fachlich umgedeutet.
- PingCastle-Profil 1.4.0 klassifiziert bekannte Tabellen mit den normalisierten sichtbaren PingCastle-Bezeichnungen `domain-information`, `computer-information`, `user-information`, `admin-groups`, `control-paths-analysis`, `infrastructure`, `anomalies`, `password-policies`, `gpo`, `trusts-details` und `pki`. Jeder sichtbare Environment-Unterpunkt wird im flachen Subject-Feld als Punktpfad an seinen Hauptbereich gebunden, beispielsweise `domain-information.entra-id-configuration`, `computer-information.operating-systems`, `infrastructure.wsus-settings`, `anomalies.windows-event-forwarding-wef` oder `gpo.firewall-configuration`. Tabellen mit null Zeilen tragen den Unterpunkt ueber ihre Row-Count-/Presence-Beobachtung; Unterpunkte ohne Tabelle erhalten eine quellentreue Heading-Presence-Beobachtung. Backup, LAPS, WEF und krbtgt bleiben entsprechend dem Bericht unter Anomalies. Finding-Regeldetailabschnitte werden nicht als Environment-Unterpunkte umgedeutet, und unbekannte Tabellen bleiben verlustfrei `pingcastle-table-row`. Bestehende 1.3.0-Importe benoetigen fuer die neuen Subject-Typen einen Reimport.
- PingCastle-Profil 1.3.0 ist die erste eindeutig versionierte Verarbeitungsrevision mit verbindlicher Environment-Context-Erfassung. Historische 1.2.0-Importe bleiben unveraendert gespeichert, gelten aber auch bei gleichem Finding-Kompatibilitaetsschluessel als `reimport-recommended`; nur ein neuer 1.3.0-Import darf als `current` erscheinen.
- Das Dashboard zeigt den aktiven Timeslot als Directory Overview mit zentralen Fakten, Betriebssystemen, hervorgehobenen Infrastruktur-/PKI-/Trust-/LAPS-Bereichen und einer Coverage-Zusammenfassung der Erfassungszustaende.
- Der Environment-Result-Vertikalschnitt ist mit 36 fokussierten synthetischen Domain-, Local-Repository-, PostgreSQL-Vertrags-, API-, HTTP- und UI-Tests verifiziert. Der Gesamtlauf besteht mit 703 Tests und ueberspringt 8 optionale PostgreSQL-Integrationstests; Local-only und der vollstaendige Client-/Server-Produktionsbuild sind erfolgreich. Nur der bereits dokumentierte globale Designcheck fuer zwei Schattenregeln in `datahub-redesign.css` Zeilen 39/42 bleibt rot. Die reale PostgreSQL-Laufzeitintegration fuer Migration 018 bleibt offen; Phase 13C bleibt deshalb in Arbeit.
@ -1352,6 +1353,7 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04
| 2026-08-23 | Scanner-Kontext wird zweistufig gespeichert: quellengetreue Reportabschnitte mit Prosa und Tabellen sowie typisierte EnvironmentObservations mit stabilen Schluesseln und explizitem Erfassungszustand. Findings bleiben ein getrennter Vertrag. | Nur die Rohstruktur verhindert Informationsverlust bei neuen PingCastle-Versionen; nur typisierte Fakten erlauben zugleich belastbare Dashboard-, Result-DB-, LLM- und Toolabfragen. Ein Zustandsfeld trennt echte Abwesenheit von leerer Ausgabe, fehlender Berechtigung, Lizenzgrenze und Parserluecke. |
| 2026-08-23 | Typisierte Scanner-EnvironmentObservations werden als eigener Unified-Result-Quelltyp `environment-observation` projiziert und niemals als Finding umgedeutet. Die Projektion ist ein abgeleitetes Readmodell ueber die bestehende Environment-Persistenz; sie bewahrt Original-/Normalwert und vollstaendige Provenienz, bietet eigene Environment-Facetten und nimmt diese Zeilen aus Finding-spezifischen Facetten heraus. | Result DB, Exporte, LLMs und weitere Tools benoetigen einen gemeinsamen timeslotgebundenen Zugriff, ohne aus DFL, OS, PKI, Trusts oder Coverage kuenstliche Schweregrade, IOEs, Abweichungen oder Reviews abzuleiten. Ein Readmodell vermeidet Datenkopien und eine weitere Migration, waehrend der getrennte SourceKind den Vertrag eindeutig haelt. |
| 2026-08-23 | PingCastle Environment Context erhaelt einen eigenen Profilversionsschnitt 1.3.0 bei unveraendertem kanonischem Finding-Kompatibilitaetsschluessel `pingcastle-canonical-v1.1`. Profil 1.2.0 bleibt append-only historisch erhalten und wird als reimportbeduerftig bewertet; 1.3.0 ist der einzige aktuelle Stand. | Die Context-Erfassung war zuvor unter derselben sichtbaren Profilnummer wie contextlose Importe ausgerollt. Versionsgleichheit konnte deshalb einen technisch alten Backendimport faelschlich als `Current` markieren. Der Profilversionsschnitt macht die Datenfaehigkeit deterministisch, ohne Findings umzudeuten oder Altdaten umzuschreiben. |
| 2026-08-24 | PingCastle-Profil 1.4.0 und Extractor 2.1.0 verwenden fuer bekannte Environment-Subjects die normalisierten sichtbaren PingCastle-Bezeichnungen und bilden jeden sichtbaren Environment-Unterpunkt als Punktpfad unter seinem Hauptbereich ab. Der Extractor bindet Tabellen ueber den unmittelbar vorangehenden H2-Berichtstitel, ordnet leere Tabellen ueber Row Count/Presence ein und erzeugt fuer Heading-only-Unterpunkte ausschliesslich eine quellentreue Presence-Beobachtung. Prosa-Fakten fuer Entra ID Configuration, Backup, LAPS, WEF und krbtgt erhalten explizite Pfade; Finding-Regeldetailbereiche bleiben ausgeschlossen. Profil 1.3.0 bleibt append-only historisch und wird reimportbeduerftig; der Finding-Kompatibilitaetsschluessel bleibt `pingcastle-canonical-v1.1`. | Die vorherige ASSESSOR-Taxonomie `active-directory-*` und reine Section-Key-Klassifikation entsprachen weder der sichtbaren Berichtshierarchie noch Unterpunkten ohne gefuellte Tabelle. Punktpfade erhalten diese Hierarchie im flachen Subject-Feld, waehrend Originalwerte, Tabellen-/Heading-Provenienz und der generische Fallback erhalten bleiben und keine Finding-Semantik in Environment Context gelangt. |
| 2026-08-23 | Geoeffnete Pathfinder-Kacheln werden in einer eigenen, nach Kanten und Knoten gerenderten SVG-Kartenebene dargestellt. Die Platzierung waehlt anhand des sichtbaren Raums links oder rechts des Bezugspunkts, haelt einen festen Abstand, bleibt innerhalb der Graphflaeche und verschiebt mehrere Karten kollisionsfrei vertikal. Finding-Kacheln reservieren feste Bereiche fuer maximal fuenf Titelzeilen, zwei vollstaendige Badgezeilen und die rechte Aktionsspalte; mehr als vier Badges werden fachlich priorisiert und mit `+N` zusammengefasst. | SVG-Malreihenfolge und DOM-Nachbarschaft hatten spaeter gezeichnete Punkte vor Karten gelegt. Eine eigene letzte Ebene beseitigt diesen Fehler deterministisch. Feste Inhaltsbereiche verhindern teilweise abgeschnittene Badges; ein sichtbarer Ueberlaufzaehler erhaelt die Information, ohne die bestaetigte Kachelgroesse oder das gemeinsame Zoomverhalten zu veraendern. |
| 2026-08-23 | Die bildlich bestaetigte Pathfinder-Kachel verwendet eine feste, vollstaendige Vorlage statt der zuvor verdichteten 148-mal-72-px-Findingstruktur: Findings werden intern auf 240 mal 176 px, Rollen auf 148 mal 72 px gesetzt und beide einmalig mit dem konstanten Graphfaktor 0,4 eingebettet. Danach skaliert die komplette Komposition ausschliesslich gemeinsam mit der SVG-Kamera. CIS-Hierarchie-Badges gehoeren wieder zur Finding-Kachel. | Das Negativbeispiel zeigte, dass die kleine logische Findingflaeche bei starker Graphvergroesserung Titel und Metadaten uebergross und abgeschnitten darstellte. Eine feste ausreichend grosse Vorlage bewahrt Titel, Status, Gewicht, CIS-Kategorien und Aktionen; der konstante Einbettungsfaktor veraendert sich beim Zoomen nicht und laesst die Kachel wie ein Bild skalieren. Diese Bildreferenz ersetzt die unmittelbar vorherige Entscheidung, CIS-Badges in der Pathfinder-Kachel auszulassen. |
| 2026-08-23 | Der Backend-Verbindungszustand steht in jedem Bereichsheader unmittelbar vor dem Clientnamen und wird ohne Text oder Pillenrahmen als Paar aus einem roten und einem gruenen 9-mal-9-px-Quadrat dargestellt. Gruen ist bei bestaetigter Verbindung voll sichtbar; Rot ist bei fehlender oder noch ungepruefter Verbindung voll sichtbar, der jeweils andere Zustand bleibt gedimmt. | Die kompakte binaere Anzeige bewahrt den technischen Zustand, ohne den ohnehin knappen rechten Headerbereich mit `Server connected` zu belegen. Ein dauerhaftes Rot-/Gruen-Paar macht beide Bedeutungen wiedererkennbar; ARIA-Label und Tooltip erhalten die textliche Aussage fuer assistive Technik. |
@ -1725,6 +1727,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| UI-Tabellenprojektion und Candidate-Mapping erzeugen unterschiedliche Findinganzahlen oder semantische Werte. | Candidate-Mapping als einzige Persistenzquelle für neue Importe, identische Vorschauzählung, Diagnose nicht ausgewählter Tabellen, validierte Bestätigungs-IDs, Legacy-Fallback nur ohne Mapping sowie format- und scannerübergreifende Paritätsregressionen. |
| Persistente Ingestion Jobs werden doppelt ausgeführt, bleiben nach Worker-Abbruch hängen oder erzeugen durch Wiederholungen falsche Änderungsstände. | Projektweit eindeutige Idempotenzschlüssel, atomare `SKIP LOCKED`-Claims, zeitlich begrenzte Leases, begrenzte Versuche, kontrollierte Lease-Recovery, Dead Letter und revisionsneutrale Wiederholungen beziehungsweise Leerlauf-Polls. |
| Unbekannte Berichtslayouts werden falsch erkannt. | Konfidenzen, generischer Fallback, Importvorschau und bestätigbare Mappings. |
| PingCastle benennt oder strukturiert bekannte Environment-Abschnitte, H2-Unterpunkte oder Tabellen kuenftig anders und eine Subject-Klassifikation greift nicht mehr oder zu breit. | Section-Key, unmittelbar vorangehenden sichtbaren H2-Titel, Tabellenrolle und Header kombinieren; Row Count/Presence fuer leere Tabellen und quellentreue Heading-Presence fuer tabellenlose Unterpunkte bewahren. Finding-Regeldetailabschnitte ausschliessen, unbekannte Strukturen als `pingcastle-table-row` erhalten und die vollstaendige sichtbare Environment-Hierarchie sowie negative Fallback-/Rule-Detail-Faelle synthetisch regressionspruefen. |
| Quelltools ändern ihre Exportformate. | Versionierte Profile, Fixture-Tests und Erkennung über mehrere Signale. |
| Ähnliche oder gleich betitelte Findings werden falsch zusammengeführt. | Importlokale unresolved Identitäten, automatische Zuordnung nur über stabile technische IDs, Eindeutigkeitsprüfung pro Stufe und Titelähnlichkeit ausschließlich als bestätigungspflichtiger Reviewvorschlag. |
| Große oder manipulierte Dateien blockieren die Anwendung. | Ressourcenlimits, sichere Parserkonfiguration, Belastungstests, gecachte Result-Verknüpfungen, memoierte Datensatznavigation, lazy gerenderte Result-Tabellen und verzögerte lokale Review-Persistenz. |
@ -1777,6 +1780,16 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-08-24 | Phase-7-Dashboard-IOE-Kennzahl als Filtereinstieg umgesetzt: `IOEs found` ist bei vorhandenem Trefferbestand anklickbar, setzt bestehende Dashboardfilter zurueck, uebergibt exakt die als explizite `IOE Found`/`IOEs found` gezaehlten Datensatz-IDs an die vorhandene Work-Teilmenge und oeffnet Work. Bei null Treffern bleibt das Steuerelement deaktiviert, damit kein ungefilterter Bestand erscheint. Zehn fokussierte Dashboard- und Filtertests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Ringspur nach Nutzerreferenz sichtbar gemacht: Der bislang mit dem Panel verschmelzende ungefuellte Anteil der oberen Statusringe verwendet nun den vorhandenen neutralgrauen Donut-Rahmentoken; Farbbogen, Geometrie, Werte und Interaktionen bleiben unveraendert. Sieben fokussierte Dashboardtests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Statusleiste nach Nutzerreferenz korrigiert: Die sechs gleichartigen Donutkarten sind durch eine fuenfteilige Leiste ersetzt. Compliance Score erhaelt den grossen Ring mit Formelhinweis; Deviation, Passed und Not Run verwenden kompakte Ringe mit realer Anzahl und Prozentwert. Not Applicable und Error stehen gemeinsam in einer Zusammenfassung neben `IOEs found`. Der IOE-Wert zaehlt ausschliesslich explizite `IOE Found`-/`IOEs found`-Quellenfelder und wird nicht aus Deviations abgeleitet; Statuskarten bleiben direkte Filtereinstiege in Work. 14 fokussierte Dashboard-, Donut- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile-Selektor um `Full Risk Profile` erweitert: Dieser Modus umfasst alle aktiven Findings, zeigt links statt Kategorien die real vorhandenen Werkzeuge mit ueber mehrere Zeitstaende aggregierten Findingzahlen und berechnet Severity-Ring, Marked-Zahl und Deviation-Nenner ueber den Gesamtbestand. Ein Werkzeugklick oeffnet Work mit genau diesem Quellenfilter; Werkzeugprofile behalten links ihre eigenen Kategorien. PingCastle bleibt beim ersten Oeffnen der Standard, sofern vorhanden. 18 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-PingCastle-Risk-Profile um explizite Nullkategorien korrigiert: Die vier feste Scanner-Skala Anomalies, Stale Objects, Privileged Accounts und Trusts wird aus den strukturierten `risk.*`-Environment-Beobachtungen aufgebaut und filtert Nullwerte nicht mehr weg; `Trusts 0` bleibt damit sichtbar. Aeltere Importe ohne Risk-Observations fallen auf die werkzeugbezogenen Facettenzaehler zurueck, behalten aber alle vier Zeilen. Environment Snapshot wird fuer Directory Overview und Risk Profile gemeinsam einmal geladen. 15 fokussierte Dashboard- und Facettentests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile werkzeugbezogen korrigiert: Die bisherige statische Panelueberschrift ist ein klickbarer Tool-Selektor fuer PingCastle, PurpleKnight, CIS-CAT, Maester und alle weiteren real vorhandenen Scanquellen. Mehrere Zeitstaende desselben Werkzeugs werden zu genau einer Auswahl gruppiert; PingCastle ist der Standard, wenn vorhanden. Kategorien, Severity-Ring, Legendenzahlen, Marked-Zahl und Deviation-Nenner werden ausschliesslich aus den Datensaetzen des gewaehlten Werkzeugs neu gezaehlt. Kategorie- und Severity-Klicks uebergeben Werkzeug plus Auswahl gemeinsam an Work. 17 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Unterzeile nach Nutzerreferenz korrigiert: Directory Overview zeigt vier getrennte Coverage-Kennzahlen oberhalb eines festen vierspaltigen Faktenrasters; die bisherigen Coverage-Chips und die wiederholte Summenzeile entfallen. Das rechte Panel ist nun ein Risk Profile mit `Marked for report` im Kopf, den vier staerksten realen Kategorien links sowie einem segmentierten Severity-Ring und Legende rechts. Kategorien und Severity bleiben direkte Filtereinstiege in Work; bei fehlenden Facetten erscheinen ehrliche Leerzustaende. Sieben fokussierte Dashboard-/Inset-Tests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Scanquellen nach Nutzerkorrektur gekuerzt: Die Legende der Scan Source Distribution entfernt ausschliesslich einen abschliessenden gueltigen ISO-Tag beziehungsweise ISO-Zeitstempel aus `Werkzeug · Zeitstempel`; Werkzeugname, interner Facettenwert, Provenienz und Filter-Sprung nach Work bleiben unveraendert. Drei fokussierte Tests und Client-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard nach Nutzerreferenz als kompaktes Analyseboard neu geordnet: Der bestehende Donut-Standard zeigt Compliance Score sowie reale Statuszahlen fuer Deviation, Passed, Not Applicable, Not Run und Error; Statuskarten bleiben direkte Filtereinstiege in Work. Severity und Scanquellen erscheinen als zweispaltige segmentierte Verteilungen mit anklickbarer Legende, Directory Overview und Reportmarkierung bilden die untere Informationszeile. Der verbindliche 20-px-Panelinset und 16-px-Inhaltstext bleiben erhalten; keine erfundenen Kennzahlen, keine Import-, API-, Persistenz- oder Schemaaenderung. Neun fokussierte Layout-/Donut-/Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 721 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der bekannte Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-13C-PingCastle-Environment-Subjects nachgehaertet: Profil 1.4.0, Mapping 1.2.0 und Extractor 2.1.0 verwenden normalisierte PingCastle-Berichtsnamen und Punktpfade fuer jeden sichtbaren Environment-Unterpunkt. Dazu gehoeren unter anderem `domain-information.entra-id-configuration`, die User-/Computer-Analysen, Admin-Groups-/Control-Paths-Unterpunkte, Trusts, PKI, alle Infrastructure-, Anomalies-, Password-Policies- und GPO-Unterpunkte. Leere Tabellen werden ueber Row Count/Presence, tabellenlose H2-Bereiche ueber eine quellentreue Presence-Beobachtung sichtbar; Backup, LAPS, WEF und krbtgt sind entsprechend dem Bericht unter Anomalies korrigiert. Finding-Regeldetailbereiche bleiben ausgeschlossen und unbekannte Tabellen behalten `pingcastle-table-row`. Ein ausschliesslich temporaerer, danach entfernter Read-only-Test gegen den ausdruecklich freigegebenen Dummy-Bericht bestaetigte 48 hierarchische Unterpunkt-Typen; persistierte Inhalte wurden nicht veraendert. 47 fokussierte synthetische Tests, Local-only-Sicherheitspruefung und Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, SQL- oder Migrationsaenderung; bestehende PingCastle-1.3.0-Importe benoetigen einen Reimport. Phase 13C bleibt in Arbeit. |
| 2026-08-24 | Phase-7/12/13A-Reimportidentitaet fail closed nachgehaertet: Ein zielgebundener Scanner-Reimport akzeptiert nur noch das exakte SHA-256-Multiset aller Dateien des zu ersetzenden ScanRuns. Preview prueft die hochgeladenen Bytes vor Parsing und Persistenz; Complete prueft die final ausgewaehlten Vorschauobjekte erneut; Local- und PostgreSQL-Repository erzwingen die Invariante bei kanonischem Ersatz und bei der Uebernahme eines bereits importierten Ersatzlaufs. Fehlende Originalhashes, abweichende Bytes, fehlende oder zusaetzliche Dateien sowie der nicht verifizierbare Offline-Fallback werden mit eindeutiger Fehlermeldung abgelehnt; der alte Scan bleibt aktiv. Datahub bietet einen vorhandenen Ersatzlauf nur bei Hashgleichheit an, und der Reimportdialog akzeptiert keine bloss wahrscheinliche Dublette mehr. 31 fokussierte synthetische Tests und der vollstaendige Client-/Server-Produktionsbuild erfolgreich; Gesamtlauf 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der Gesamtskriptstatus bleibt ausschliesslich am bekannten Designcheck fuer die unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; die Reimportaenderung erzeugt keinen neuen Designbefund. Keine Migration, keine Realdaten gelesen oder mutiert; Phasen 7, 12 und 13A bleiben abgeschlossen. |
| 2026-08-24 | Phase-7/12-Datahub-Readiness auf mandantenweite Vergleichsbereitschaft korrigiert: Die Kachel fasst nicht mehr nur den neuesten `Open`-Timeslot zusammen, sondern alle aktiven scannergebundenen Timeslots. Sobald irgendein aktiver Altstand einen Reimport auf den aktuellen ASSESSOR-Verarbeitungsvertrag benoetigt, erscheint der Mandant gelb und `Manage` dauerhaft; inkompatible oder unbekannte Vertraege bleiben rot. `Manage` klappt saemtliche betroffenen Timeslots automatisch auf, auch wenn sie weder aktuell noch ausgewaehlt sind; `Open` oeffnet weiterhin den neuesten Timeslot. Damit kann ein aktueller neuer Stand einen fuer spaetere Lifecycle-Vergleiche relevanten Altstand nicht mehr gruen ueberdecken. Zwoelf fokussierte Datahub-Tests sowie Client-Bundle und Server-Produktionsbuild erfolgreich; Gesamtlauf 715 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der vollstaendige Client-Typecheck ist durch zwei parallele Pathfinder-Typfehler in `ObsidianGraphView.tsx` blockiert; der bekannte Designcheck bleibt nur an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, Persistenz-, Schema- oder Realdatenaenderung; Phase 7 und 12 bleiben abgeschlossen. |
| 2026-08-24 | Phase-7-Kopfbereich um den dauerhaft sichtbaren ASSESSOR-Verarbeitungsstand erweitert: Unter dem Markennamen erscheint ausschliesslich die zentrale Processing-Version, aktuell `1.3.0`, ohne `Version`, `v` oder weiteren Begleittext. Die Anzeige verwendet `CURRENT_ASSESSOR_PROCESSING_VERSION` aus dem Processingvertrag und ist bewusst von der technischen npm-Paketversion getrennt. Kompakte Metadaten-Typografie und tabellarische Ziffern veraendern Logo, Navigation und Kopfgeometrie nicht. Fuenf fokussierte Header-/Versionsregressionen sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; Gesamtlauf 712 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der Gesamtskriptstatus bleibt ausschliesslich am bekannten Designcheck fuer die unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; die Versionsanzeige erzeugt keinen neuen Befund. Keine API-, Persistenz-, Schema- oder Realdatenaenderung; Phase 7 bleibt abgeschlossen. |

View File

@ -18,6 +18,8 @@ interface DonutMetricCardProps {
value: number;
percentage: number;
tone: DonutMetricTone;
valueSuffix?: string;
showPercentage?: boolean;
className?: string;
active?: boolean;
onClick?(): void;
@ -28,6 +30,8 @@ export function DonutMetricCard({
value,
percentage,
tone,
valueSuffix = '',
showPercentage = true,
className,
active,
onClick
@ -46,8 +50,8 @@ export function DonutMetricCard({
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="100" cy="100" r="78" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/>
</svg>
<div className="donut-metric-card__values">
<strong>{value.toLocaleString('en-US')}</strong>
<span>{boundedPercentage}%</span>
<strong>{value.toLocaleString('en-US')}{valueSuffix}</strong>
{showPercentage && <span>{boundedPercentage}%</span>}
</div>
</div>
</>;

View File

@ -29,7 +29,8 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
{ profileId: 'pingcastle', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'pingcastle-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: false, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'presentation-only', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: true },
{ profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true },
{ profileId: 'cis-cat', profileVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.2.0', presentationVersion: '1.2.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence'], current: false },
{ profileId: 'cis-cat', profileVersion: '1.3.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: ['evidence', 'presentation'], current: true },
{ profileId: 'maester', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true }

View File

@ -3,72 +3,142 @@ import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
import { deriveDynamicFacets, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
const assessorApi = new AssessorApiClient();
interface DashboardPanelProps {
/** Wechselt in die Arbeitsflaeche, nachdem der Filter gesetzt wurde. */
onOpenWork(): void;
}
/**
* Die Balkenbreite ist ein Messwert und kann deshalb nicht in src/styles
* stehen. Uebergeben wird nur der Anteil, die Darstellung bleibt im CSS.
*/
function shareStyle(share: number): CSSProperties {
return { '--share': `${share}%` } as unknown as CSSProperties;
function shareStyle(value: number): CSSProperties {
return { '--share': `${value}%` } as unknown as CSSProperties;
}
function share(count: number, total: number): number {
return total > 0 ? (count / total) * 100 : 0;
}
interface DistributionProps {
eyebrow: string;
export function dashboardExplicitIoeCount(records: FindingRecord[]): number {
return dashboardExplicitIoeRecordIds(records).length;
}
export function dashboardExplicitIoeRecordIds(records: FindingRecord[]): string[] {
return records
.filter(record => record.fields.some(field => /^ioes?\s+found$/i.test(field.value.trim())))
.map(record => record.id);
}
interface DashboardStatusMetricProps {
label: string;
value: number;
percentage: number;
tone: 'all' | 'deviation' | 'passed' | 'not-run';
large?: boolean;
helper?: string;
active?: boolean;
onClick?(): void;
}
function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) {
const roundedPercentage = Math.min(100, Math.max(0, Math.round(percentage)));
const content = <>
<div className={`dashboard-status-ring dashboard-status-ring-${tone}`}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} />
</svg>
<span><strong>{roundedPercentage}</strong><small>%</small></span>
</div>
<div className="dashboard-status-copy">
<span>{label}</span>
<strong>{large ? `${roundedPercentage} %` : <>{value.toLocaleString('en-US')} <small>· {roundedPercentage}%</small></>}</strong>
{helper && <small>{helper}</small>}
</div>
</>;
const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`;
return onClick
? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${roundedPercentage}%`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>;
}
interface SegmentedDistributionProps {
heading: string;
question: string;
total: number;
values: DynamicFacetValue[];
severity?: boolean;
formatLabel?(label: string): string;
onSelect(value: string): void;
}
/**
* Eine Verteilung als Anteilsbalken. Jede Zeile ist zugleich der Sprung in die
* Arbeitsflaeche mit genau diesem Filter - eine Zahl, die man nicht verfolgen
* kann, beantwortet keine Frage.
*/
function Distribution({ eyebrow, heading, question, total, values, severity = false, onSelect }: DistributionProps) {
export function dashboardScanSourceLabel(label: string): string {
const parts = label.split(/\s+·\s+/);
const timestamp = parts.at(-1) ?? '';
if (parts.length < 2 || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(timestamp) || Number.isNaN(Date.parse(timestamp))) return label;
return parts.slice(0, -1).join(' · ');
}
export interface DashboardRiskTool {
id: string;
label: string;
sourceValues: string[];
count: number;
}
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
const tools = new Map<string, DashboardRiskTool>();
values.forEach(value => {
const label = dashboardScanSourceLabel(value.label);
const id = label.toLocaleLowerCase('en-US');
const existing = tools.get(id);
if (existing) {
existing.sourceValues.push(value.value);
existing.count += value.count;
} else tools.set(id, { id, label, sourceValues: [value.value], count: value.count });
});
return [...tools.values()];
}
/** Compact distribution based on the supplied dashboard reference. */
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
return (
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>{eyebrow}</span><h3>{heading}</h3></div>
<strong>{total.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">{question}</p>
<section className="dashboard-block dashboard-distribution">
<h2>{heading}</h2>
{values.length === 0 ? (
<p className="dashboard-empty">This scan report carries no such classification.</p>
) : (
<ul className="dashboard-bars">
{values.map(value => (
<li key={value.value}>
<button
className={`dashboard-bar${severity ? ` severity-${value.value}` : ''}`}
type="button"
onClick={() => onSelect(value.value)}
title={`Open in Work, filtered by ${value.label}`}
>
<span className="dashboard-bar-label">{value.label}</span>
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(value.count, total))} />
</span>
<span className="dashboard-bar-count">{value.count.toLocaleString('en-US')}</span>
<span className="dashboard-bar-share">{share(value.count, total).toFixed(0)}%</span>
</button>
</li>
))}
</ul>
<>
<div className="dashboard-segmented-bar" aria-hidden="true">
{values.map((value, index) => (
<span
className={`dashboard-segment ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`}
key={value.value}
style={shareStyle(share(value.count, total))}
/>
))}
</div>
<ul className="dashboard-distribution-legend">
{values.map((value, index) => (
<li key={value.value}>
<button
className="dashboard-legend-button"
type="button"
onClick={() => onSelect(value.value)}
title={`Open in Work, filtered by ${value.label}`}
>
<span className={`dashboard-legend-swatch ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`} aria-hidden="true" />
<span className="dashboard-legend-copy">
<span>{formatLabel(value.label)}</span>
<strong>{value.count.toLocaleString('en-US')} <small>· {share(value.count, total).toFixed(0)}%</small></strong>
</span>
</button>
</li>
))}
</ul>
</>
)}
</section>
);
@ -97,7 +167,7 @@ function displayValue(observation: EnvironmentObservation): string {
return String(value);
}
function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; scanRunIds?: string[] }) {
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
const [failed, setFailed] = useState(false);
@ -112,6 +182,11 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
return () => { active = false; };
}, [projectId, scanRunIds?.join('|')]);
return { snapshot, failed };
}
function EnvironmentOverview({ projectId, snapshot, failed }: { projectId?: string; snapshot?: EnvironmentSnapshot; failed: boolean }) {
const observations = useMemo(() => {
const byProperty = new Map<string, EnvironmentObservation>();
snapshot?.observations.forEach(observation => byProperty.set(observation.property, observation));
@ -122,78 +197,203 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
const operatingSystems = useMemo(() => [...new Set(snapshot?.observations
.filter(item => item.property === 'operating_system.name')
.map(displayValue) ?? [])], [snapshot]);
const highlightedSections = useMemo(() => snapshot?.sections.filter(section =>
/pki|certificate|trust|infrastructure|entra|laps|password|domain information|computer information/i.test(`${section.sectionKey} ${section.label}`)
) ?? [], [snapshot]);
if (!projectId) return null;
return (
<section className="dashboard-environment" aria-labelledby="environment-overview-heading">
<div className="datahub-section-heading">
<div><span>Environment</span><h3 id="environment-overview-heading">Directory overview</h3></div>
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
</div>
<p className="dashboard-question">What did the scanner observe about the environment beyond individual findings?</p>
{!snapshot && !failed && <p className="dashboard-empty">Loading scanner context…</p>}
{failed && <p className="dashboard-empty">Scanner context could not be loaded.</p>}
{snapshot && snapshot.sections.length === 0 && <p className="dashboard-empty">This import predates structured scanner-context capture.</p>}
{snapshot && (
<div className="dashboard-coverage-summary" aria-label="Directory coverage summary">
<span><strong>{snapshot.observations.length.toLocaleString('en-US')}</strong><small>typed observations</small></span>
<span><strong>{(snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent).toLocaleString('en-US')}</strong><small>empty or absent</small></span>
<span><strong>{snapshot.coverage.restricted.toLocaleString('en-US')}</strong><small>restricted</small></span>
<span><strong>{snapshot.coverage.incomplete.toLocaleString('en-US')}</strong><small>incomplete</small></span>
</div>
)}
{snapshot && (observations.length > 0 || operatingSystems.length > 0) && (
<dl className="dashboard-facts">
{observations.map(({ label, observation }) => (
<div key={observation.property}><dt>{label}</dt><dd>{displayValue(observation)}</dd></div>
))}
{operatingSystems.length > 0 && <div><dt>Operating systems</dt><dd>{operatingSystems.join(', ')}</dd></div>}
{operatingSystems.length > 0 && <div><dt>OS</dt><dd>{operatingSystems.join(', ')}</dd></div>}
</dl>
)}
{snapshot && highlightedSections.length > 0 && (
<div className="dashboard-coverage" aria-label="Captured environment areas">
{highlightedSections.map(section => (
<span key={section.id} className={`dashboard-state dashboard-state-${section.state}`} title={section.label}>
{section.label} · {section.state.replace('-', ' ')}
</span>
))}
</section>
);
}
interface RiskProfileProps {
sourceFacet?: DynamicFacet;
categoryFacet?: DynamicFacet;
severityFacet?: DynamicFacet;
records: FindingRecord[];
profile: ImportProfile;
reviews: Record<string, Review>;
environmentSnapshot?: EnvironmentSnapshot;
onSelectTool(sourceValues: string[]): void;
onSelectCategory(value: string, sourceValues: string[]): void;
onSelectSeverity(value: string, sourceValues: string[]): void;
}
type RiskScoreObservation = Pick<EnvironmentObservation, 'property' | 'normalizedValue' | 'originalValue'>;
const pingCastleRiskCategoryCatalog = [
['risk.anomalies', 'Anomalies'],
['risk.stale_objects', 'Stale Objects'],
['risk.privileged_accounts', 'Privileged Accounts'],
['risk.trusts', 'Trusts']
] as const;
export function dashboardPingCastleRiskCategories(observations: RiskScoreObservation[], fallback: DynamicFacetValue[]): DynamicFacetValue[] {
return pingCastleRiskCategoryCatalog.map(([property, label]) => {
const observation = observations.find(item => item.property === property);
const sourceValue = observation?.normalizedValue ?? observation?.originalValue;
const numericValue = typeof sourceValue === 'number' ? sourceValue : Number(sourceValue);
const fallbackValue = fallback.find(value => value.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'));
return {
value: fallbackValue?.value ?? label.toLocaleLowerCase('en-US'),
label,
count: Number.isFinite(numericValue) ? numericValue : fallbackValue?.count ?? 0
};
});
}
const fullRiskProfileId = 'full-risk-profile';
function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profile, reviews, environmentSnapshot, onSelectTool, onSelectCategory, onSelectSeverity }: RiskProfileProps) {
const tools = useMemo(() => dashboardRiskTools(sourceFacet?.values ?? []), [sourceFacet]);
const [selectedToolId, setSelectedToolId] = useState('');
const selectedTool = tools.find(tool => tool.id === selectedToolId)
?? tools.find(tool => tool.id.includes('pingcastle'))
?? tools[0];
const fullProfile = selectedToolId === fullRiskProfileId;
const effectiveProfileId = fullProfile ? fullRiskProfileId : selectedTool?.id ?? fullRiskProfileId;
const scopedRecords = useMemo(() => fullProfile
? records
: selectedTool && sourceFacet
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: selectedTool.sourceValues } }, reviews))
: [], [fullProfile, records, reviews, selectedTool, sourceFacet]);
const scopedFacets = useMemo(() => recountFacetsAgainstActiveFilters({
facets: [categoryFacet, severityFacet].filter((facet): facet is DynamicFacet => Boolean(facet)),
records: scopedRecords,
filters: {},
reviews,
statusFilter: 'all',
matchesStatus: (record, status) => profile.matchesStatus(record, status)
}), [categoryFacet, profile, reviews, scopedRecords, severityFacet]);
const scopedCategories = scopedFacets.find(facet => facet.id === categoryFacet?.id)?.values ?? [];
const categories = fullProfile
? tools.map(tool => ({ value: tool.id, label: tool.label, count: tool.count }))
: selectedTool?.id.includes('pingcastle')
? dashboardPingCastleRiskCategories(
environmentSnapshot?.observations.filter(observation => observation.tool.id === 'pingcastle') ?? [],
scopedCategories
)
: [...scopedCategories].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
const severities = scopedFacets.find(facet => facet.id === severityFacet?.id)?.values ?? [];
const deviations = scopedRecords.filter(record => profile.matchesStatus(record, 'deviation')).length;
const marked = scopedRecords.filter(record => reviews[record.id]?.include).length;
const severityTotal = severities.reduce((sum, value) => sum + value.count, 0);
let severityOffset = 0;
return (
<section className="dashboard-block dashboard-risk-profile">
<header className="dashboard-risk-header">
{tools.length > 0 ? <select className="dashboard-risk-tool" aria-label="Risk profile tool" value={effectiveProfileId} onChange={event => setSelectedToolId(event.target.value)}>
<option value={fullRiskProfileId}>Full Risk Profile</option>
{tools.map(tool => <option value={tool.id} key={tool.id}>{tool.label} Risk Profile</option>)}
</select> : <h2>Risk profile</h2>}
<div className="dashboard-marked-summary"><span>Marked for report</span><strong>{marked.toLocaleString('en-US')} / {deviations.toLocaleString('en-US')}</strong></div>
</header>
<div className="dashboard-risk-body">
{categories.length > 0 ? (
<ul className="dashboard-risk-categories">
{categories.slice(0, 4).map(category => (
<li key={category.value}>
<button type="button" onClick={() => {
if (fullProfile) {
const tool = tools.find(item => item.id === category.value);
if (tool) onSelectTool(tool.sourceValues);
} else if (selectedTool) onSelectCategory(category.value, selectedTool.sourceValues);
}} title={`Open in Work, filtered by ${category.label}`}>
<span>{category.label}</span><strong>{category.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
) : <p className="dashboard-empty">No risk categories available.</p>}
<div className="dashboard-risk-severity">
{severities.length > 0 ? <>
<svg className="dashboard-risk-donut" viewBox="0 0 120 120" role="img" aria-label="Risk severity distribution">
<circle className="dashboard-risk-donut-track" cx="60" cy="60" r="48" pathLength="100" />
{severities.map(severity => {
const percentage = share(severity.count, severityTotal);
const offset = severityOffset;
severityOffset += percentage;
return <circle
className={`dashboard-risk-donut-segment severity-${severity.value}`}
cx="60"
cy="60"
r="48"
key={severity.value}
pathLength="100"
strokeDasharray={`${percentage} ${100 - percentage}`}
strokeDashoffset={-offset}
/>;
})}
</svg>
<ul className="dashboard-risk-legend">
{severities.map(severity => (
<li key={severity.value}>
<button type="button" onClick={() => fullProfile
? onSelectSeverity(severity.value, [])
: selectedTool && onSelectSeverity(severity.value, selectedTool.sourceValues)} title={`Open in Work, filtered by ${severity.label}`}>
<i className={`severity-${severity.value}`} aria-hidden="true" />
<span>{severity.label}</span>
<strong>{severity.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
</> : <p className="dashboard-empty">No severity distribution available.</p>}
</div>
)}
{snapshot && (
<p className="dashboard-context-coverage">
{snapshot.observations.length.toLocaleString('en-US')} typed observations · {snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent} empty or absent · {snapshot.coverage.restricted} restricted · {snapshot.coverage.incomplete} incomplete
</p>
)}
</div>
</section>
);
}
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, resetFilters, setView } = app;
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const facets = useMemo(
() => (activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : []),
[activeProfile, activeSheet, dataset, reviews]
);
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
if (!dataset || !activeSheet) return <EmptyWorkspace />;
const total = activeSheet.analysis.records.length;
const statusFacet: DynamicFacet | undefined = facets.find(facet => facet.source === 'profile-status');
const severityFacet: DynamicFacet | undefined = facets.find(facet => facet.severityScale);
/*
* Herkunft der Datensaetze. In der Projektaggregation traegt sie das Feld
* "Quelle: Scan" ("<Werkzeug> · <Zeitpunkt>"), bei einem einzelnen Import die
* Ersatzfacette ueber den Dateinamen. `dataset.sheets.length` waere hier
* falsch: darin stecken auch die verlinkten Ergebnistabellen, nicht nur die
* Scanberichte.
*/
const categoryFacet: DynamicFacet | undefined = facets.find(facet => facet.role === 'category' || /^(?:category|kategorie)$/i.test(facet.label.trim()));
const sourceFacet: DynamicFacet | undefined = facets.find(facet =>
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
const reports = sourceFacet?.values.length ?? 0;
// "Abweichung" ist der Wert, den das Profil selbst als Befund fuehrt; er
// heisst nicht in jedem Werkzeug gleich, traegt aber immer diesen Schluessel.
const deviations = statusFacet?.values.find(value => value.value === 'deviation')?.count ?? 0;
const marked = Object.values(reviews).filter(review => review.include).length;
const statusCount = (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0;
const deviations = statusCount('deviation');
const passed = statusCount('passed');
const notApplicable = statusCount('not-applicable');
const notRun = statusCount('not-run');
const errors = statusCount('error');
const explicitIoeRecordIds = dashboardExplicitIoeRecordIds(activeSheet.analysis.records);
const explicitIoes = explicitIoeRecordIds.length;
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
/* Der Statusfilter ueberlebt Navigationsspruenge. Nur ein geklicktes
Statusdiagramm darf ihn in `apply` bewusst ersetzen. */
const jump = (apply: () => void) => {
resetFilters();
setStatusFilter(app.statusFilter);
@ -204,62 +404,74 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
return (
<div className="dashboard-page">
{/* Kontext statt Kennzahlkachel: die Zahlen darunter gelten fuer genau
diesen Bestand, und das muss ohne Rueckfrage ablesbar sein. */}
<p className="dashboard-context">
<strong>{dataset.fileName}</strong>
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
<span>{total.toLocaleString('en-US')} records</span>
</p>
<EnvironmentOverview projectId={app.targetProjectId} scanRunIds={app.activeScanRunIds} />
<div className="dashboard-columns">
<Distribution
eyebrow="Coverage"
heading="Result status"
question="How much of this scan actually produced a usable result?"
total={total}
values={statusFacet?.values ?? []}
onSelect={value => jump(() => setStatusFilter(value))}
<section className="dashboard-status-rack" aria-label="Result status overview">
<DashboardStatusMetric
label="Compliance Score"
value={complianceScore}
percentage={complianceScore}
tone="all"
large
helper="Passed / (Passed + Deviation)"
/>
<DashboardStatusMetric label="Deviation" value={deviations} percentage={share(deviations, total)} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
<DashboardStatusMetric label="Passed" value={passed} percentage={share(passed, total)} tone="passed" active={app.statusFilter === 'passed'} onClick={() => jump(() => setStatusFilter('passed'))} />
<DashboardStatusMetric label="Not Run" value={notRun} percentage={share(notRun, total)} tone="not-run" active={app.statusFilter === 'not-run'} onClick={() => jump(() => setStatusFilter('not-run'))} />
<section className="dashboard-status-summary" aria-label="Other result measures">
<div className="dashboard-status-summary-stack">
<button type="button" onClick={() => jump(() => setStatusFilter('not-applicable'))}><span>Not Applicable</span><strong>{notApplicable.toLocaleString('en-US')}</strong></button>
<button type="button" onClick={() => jump(() => setStatusFilter('error'))}><span>Error</span><strong>{errors.toLocaleString('en-US')}</strong></button>
</div>
<button className="dashboard-ioe-summary" type="button" disabled={explicitIoes === 0}
onClick={() => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); })}
title="Open IOEs in Work">
<span>IOEs found</span><strong>{explicitIoes.toLocaleString('en-US')}</strong>
</button>
</section>
</section>
<Distribution
eyebrow="Priority"
heading="Severity"
question="Which findings warrant the first look?"
<div className="dashboard-distribution-grid">
<SegmentedDistribution
heading="Severity distribution"
total={total}
values={severityFacet?.values ?? []}
severity
onSelect={value => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: [value] }))}
/>
<Distribution
eyebrow="Origin"
heading="Scan reports"
question="Which report contributed which share of the records?"
<SegmentedDistribution
heading="Scan source distribution"
total={total}
values={sourceFacet?.values ?? []}
formatLabel={dashboardScanSourceLabel}
onSelect={value => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: [value] }))}
/>
</div>
{/* Bearbeitungsstand: die einzige Zahl, die nicht aus dem Bericht,
sondern aus der eigenen Arbeit stammt. */}
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>Progress</span><h3>Marked for the report</h3></div>
<strong>{marked.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">How much of the deviation backlog has been reviewed?</p>
<div className="dashboard-progress">
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(marked, deviations))} />
</span>
<span className="dashboard-progress-legend">
{marked.toLocaleString('en-US')} of {deviations.toLocaleString('en-US')} deviations
</span>
</div>
</section>
<div className="dashboard-lower-grid">
<EnvironmentOverview projectId={app.targetProjectId} snapshot={environment.snapshot} failed={environment.failed} />
<RiskProfile
sourceFacet={sourceFacet}
categoryFacet={categoryFacet}
severityFacet={severityFacet}
records={activeSheet.analysis.records}
profile={activeProfile}
reviews={reviews}
environmentSnapshot={environment.snapshot}
onSelectTool={sourceValues => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: sourceValues }))}
onSelectCategory={(value, sourceValues) => categoryFacet && sourceFacet && jump(() => {
setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(categoryFacet.id, { values: [value] });
})}
onSelectSeverity={(value, sourceValues) => severityFacet && sourceFacet && jump(() => {
if (sourceValues.length) setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(severityFacet.id, { values: [value] });
})}
/>
</div>
</div>
);

View File

@ -838,12 +838,75 @@ function semanticProperty(sectionKey: string, tableName: string, label: string):
return properties[normalized];
}
function semanticSubject(sectionKey: string): string {
if (/domaininformation/i.test(sectionKey)) return 'active-directory-domain';
if (/computerinformation|domaincontroller/i.test(sectionKey)) return 'active-directory-computer';
if (/trust/i.test(sectionKey)) return 'active-directory-trust';
if (/pki|certificate/i.test(sectionKey)) return 'public-key-infrastructure';
return 'pingcastle-table-row';
function subjectKey(value: string): string {
return value.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'value';
}
function subsectionContext(document: DecodedSourceDocument): {
tableLabels: Map<number, string>;
headings: Array<{ sectionKey: string; label: string }>;
} {
const markup = sanitizeHtmlText(document.rawText).text;
const sections = sectionSpans(markup, true);
const headings: Array<{ start: number; label: string }> = [];
const pattern = /<h2\b[^>]*>([\s\S]*?)<\/h2\s*>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(markup))) {
const label = markupText(match[1] ?? '').trim();
if (label) headings.push({ start: match.index, label });
}
const tableLabels = new Map<number, string>();
findHtmlTableSpans(markup).forEach((table, tableOrdinal) => {
const containingSections = sections
.filter(section => table.start >= section.bodyStart && table.end <= section.bodyEnd)
.sort((left, right) => (right.end - right.start) - (left.end - left.start));
const rootSection = containingSections[0];
if (!rootSection) return;
const heading = headings
.filter(candidate => candidate.start >= rootSection.bodyStart && candidate.start < table.start)
.at(-1);
const sourceTableIndex = document.tables[tableOrdinal]?.index;
if (heading && sourceTableIndex !== undefined) tableLabels.set(sourceTableIndex, heading.label);
});
return {
tableLabels,
headings: headings.flatMap(heading => {
const section = sections
.filter(candidate => heading.start >= candidate.bodyStart && heading.start < candidate.bodyEnd)
.sort((left, right) => (left.end - left.start) - (right.end - right.start))[0];
return section ? [{ sectionKey: section.sectionKey, label: heading.label }] : [];
})
};
}
function semanticSubjectBase(sectionKey: string): string | undefined {
return /domaininformation/i.test(sectionKey) ? 'domain-information'
: /computerinformation|computer/i.test(sectionKey) ? 'computer-information'
: /user/i.test(sectionKey) ? 'user-information'
: /admingroups/i.test(sectionKey) ? 'admin-groups'
: /controlpathsanalysis/i.test(sectionKey) ? 'control-paths-analysis'
: /infrastructure/i.test(sectionKey) ? 'infrastructure'
: /anomalies/i.test(sectionKey) ? 'anomalies'
: /passwordpolicies/i.test(sectionKey) ? 'password-policies'
: /^sectiongpo$/i.test(sectionKey) ? 'gpo'
: /trust/i.test(sectionKey) ? 'trusts-details'
: /pki|certificate/i.test(sectionKey) ? 'pki'
: undefined;
}
function semanticSubject(sectionKey: string, subsectionLabel: string | undefined, tableName: string, headers: string[]): string {
const normalizedHeaders = new Set(headers.map(normalizedLabel));
if (/neverexpiresuser/i.test(sectionKey)) return 'user-information.password-age-distribution';
if (/enabledtrusteddelegationcomputer/i.test(sectionKey)) return 'computer-information.account-analysis';
if (/userinformation/i.test(sectionKey) && normalizedHeaders.has('nb user accounts')) {
return 'user-information.account-analysis';
}
if (/userinformation/i.test(sectionKey) && /^account\s+list$/i.test(tableName)) {
return 'user-information.honey-pot';
}
const base = semanticSubjectBase(sectionKey);
if (!base) return 'pingcastle-table-row';
return subsectionLabel ? `${base}.${subjectKey(subsectionLabel)}` : base;
}
function typedValue(value: string): { normalizedValue: string | number | boolean; valueType: EnvironmentObservationCandidate['valueType'] } {
@ -869,6 +932,7 @@ function contextObservations(
): EnvironmentObservationCandidate[] {
const observations: EnvironmentObservationCandidate[] = [];
const reportText = markupText(sanitizeHtmlText(document.rawText).text);
const subsections = subsectionContext(document);
const reportMetadata = /Date:\s*(\d{4}-\d{2}-\d{2})\s*-\s*Engine version:\s*([^\s<]+)/i.exec(reportText);
if (reportMetadata) {
addObservation(observations, {
@ -898,8 +962,13 @@ function contextObservations(
sections.forEach(section => {
section.tables.forEach((table, tableOrdinal) => {
const subsectionLabel = table.sourceTableIndex === undefined ? undefined : subsections.tableLabels.get(table.sourceTableIndex);
const tableSubjectType = semanticSubject(section.sectionKey, subsectionLabel, table.name, table.headers);
const tableSubject = tableSubjectType === 'pingcastle-table-row'
? { type: 'report-section', identity: section.sectionKey, label: section.label }
: { type: tableSubjectType, identity: `${section.sectionKey}:${subjectKey(subsectionLabel ?? table.name)}`, label: subsectionLabel ?? table.name };
addObservation(observations, {
subject: { type: 'report-section', identity: section.sectionKey, label: section.label },
subject: tableSubject,
property: `inventory.${propertyKey(section.sectionKey)}.${propertyKey(table.name)}.row_count`,
label: `${table.name} row count`, originalValue: table.rows.length, normalizedValue: table.rows.length,
valueType: 'number', state: table.rows.length ? 'observed' : (section.state === 'observed' ? 'empty' : section.state),
@ -908,7 +977,7 @@ function contextObservations(
});
if (!table.rows.length) {
addObservation(observations, {
subject: { type: 'report-section', identity: section.sectionKey, label: section.label },
subject: tableSubject,
property: `section.${propertyKey(section.sectionKey)}.table.${tableOrdinal}.presence`, label: table.name,
originalValue: null, valueType: 'object', state: section.state === 'observed' ? 'empty' : section.state,
sectionKey: section.sectionKey,
@ -925,17 +994,44 @@ function contextObservations(
const typed = typedValue(value);
const stableProperty = semanticProperty(section.sectionKey, table.name, label);
addObservation(observations, {
subject: { type: semanticSubject(section.sectionKey), identity: subjectIdentity, label: subjectIdentity },
subject: { type: tableSubjectType, identity: subjectIdentity, label: subjectIdentity },
property: stableProperty ?? `pingcastle.${propertyKey(section.sectionKey)}.${propertyKey(table.name)}.${propertyKey(label)}`,
label, originalValue: value, ...typed, state: 'observed', sectionKey: section.sectionKey,
source: { fileName: document.file.name, tableIndex: table.sourceTableIndex, rowIndex: rowIndex + 1, columnIndex },
attributes: { tableName: table.name }
attributes: { tableName: table.name, ...(subsectionLabel ? { subsectionLabel } : {}) }
});
});
});
});
});
const tableSubsections = new Set([...subsections.tableLabels.entries()].map(([sourceTableIndex, label]) => {
const section = sections.find(candidate => candidate.tables.some(table => table.sourceTableIndex === sourceTableIndex));
return section ? `${section.sectionKey}\u0000${label}` : '';
}));
const factSubsections = new Set([
'sectionDomainInformation\u0000Entra ID Configuration',
'sectionAnomalies\u0000Backup',
'sectionAnomalies\u0000LAPS',
'sectionAnomalies\u0000Windows Event Forwarding (WEF)',
'sectionAnomalies\u0000krbtgt (Used for Golden ticket attacks)'
]);
subsections.headings.forEach(({ sectionKey, label }) => {
const key = `${sectionKey}\u0000${label}`;
if (tableSubsections.has(key) || factSubsections.has(key)) return;
if (!sections.some(section => section.sectionKey === sectionKey)) return;
const base = semanticSubjectBase(sectionKey);
if (!base) return;
const type = `${base}.${subjectKey(label)}`;
addObservation(observations, {
subject: { type, identity: `${sectionKey}:${subjectKey(label)}`, label },
property: `section.${propertyKey(sectionKey)}.subsection.${propertyKey(label)}.presence`, label,
originalValue: null, valueType: 'object', state: 'observed', sectionKey,
source: { fileName: document.file.name, objectPath: `html/sections/${sectionKey}/subsections/${subjectKey(label)}` },
attributes: { subsectionLabel: label }
});
});
const proseFacts: Array<[RegExp, string, string, EnvironmentObservationCandidate['valueType']]> = [
[/Last backup date:\s*([^\s]+(?:\s+[^\s]+)?)/i, 'backup.last_detected_at', 'Last detected AD backup', 'datetime'],
[/Legacy LAPS installation date:\s*(Never|\d{4}-\d{2}-\d{2}(?:[ T][0-9:.+-]+Z?)?)/i, 'laps.legacy.installation_date', 'Legacy LAPS installation date', 'datetime'],
@ -949,14 +1045,20 @@ function contextObservations(
if (!value) return;
const absent = /^never$/i.test(value);
const typed = declaredType === 'number' ? { normalizedValue: Number(value), valueType: declaredType } : { normalizedValue: value, valueType: declaredType };
const factSubject: [string, string?] = property === 'backup.last_detected_at' ? ['anomalies.backup', 'sectionAnomalies']
: property.startsWith('laps.') ? ['anomalies.laps', 'sectionAnomalies']
: property === 'logging.wef.configuration_count' ? ['anomalies.windows-event-forwarding-wef', 'sectionAnomalies']
: property.startsWith('kerberos.krbtgt.') ? ['anomalies.krbtgt-used-for-golden-ticket-attacks', 'sectionAnomalies']
: ['active-directory-environment', undefined];
addObservation(observations, {
subject: { type: 'active-directory-environment' }, property, label, originalValue: value,
...typed, state: absent ? 'explicitly-absent' : 'observed', source: { fileName: document.file.name, objectPath: `html/facts/${property}` }
subject: { type: factSubject[0] }, property, label, originalValue: value,
...typed, state: absent ? 'explicitly-absent' : 'observed', sectionKey: factSubject[1],
source: { fileName: document.file.name, objectPath: `html/facts/${property}` }
});
});
if (/No Entra ID configuration has been found in this domain/i.test(reportText)) {
addObservation(observations, {
subject: { type: 'active-directory-domain' }, property: 'entra.configuration_detected', label: 'Entra ID configuration detected',
subject: { type: 'domain-information.entra-id-configuration' }, property: 'entra.configuration_detected', label: 'Entra ID configuration detected',
originalValue: false, normalizedValue: false, valueType: 'boolean', state: 'explicitly-absent', sectionKey: 'sectionDomainInformation',
source: { fileName: document.file.name, objectPath: 'html/facts/entra-configuration' }
});
@ -1168,7 +1270,7 @@ function isHealthcheckXml(document: DecodedSourceDocument): boolean {
export const pingCastleSourceExtractor: SourceExtractor = {
id: 'pingcastle-healthcheck-source-extractor',
version: '2.0.0',
version: '2.1.0',
detect(document) {
if (isHealthcheckXml(document)) {
const riskRuleCount = xmlRuleGroups(document).length;

View File

@ -21,11 +21,11 @@ export const pingCastleDefinition: ToolProfileDefinition = {
schemaVersion: 1,
id: 'pingcastle',
name: 'PingCastle',
version: '1.3.0',
version: '1.4.0',
processing: {
mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible',
reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation']
reimportRequired: true, affectedDimensions: ['environment-context']
},
sourceSignals: [
{ scope: 'file-name', pattern: /ping[\s_.-]*castle|^ad[_-]hc[_-]/i, contribution: 0.5, message: 'Der Dateiname entspricht einem PingCastle-Healthcheck.' },

View File

@ -159,48 +159,122 @@
min(1180px, 100%) begrenzt zu sein. */
.db-structure-page > *, .results-browser-page > * { width: 100% !important; max-width: none !important; margin-inline: 0 !important; }
/* Dashboard: Lagebild des geoeffneten Mandanten. Jeder Block beantwortet genau
eine Frage und ist zugleich der Einstieg in die Arbeitsflaeche mit dem
passenden Filter - eine Zahl ohne Weg dorthin waere Dekoration. */
.dashboard-page { box-sizing: border-box; width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--legacy-space-2) var(--legacy-space-4); margin: 0 0 var(--legacy-space-5); color: var(--muted); font-size: var(--ui-label-size); }
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
Verteilungen bleiben direkte Einstiege in Work mit passendem Filter. */
.dashboard-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; padding: 0 var(--space-2); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-context strong { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-environment { margin: 0 0 var(--legacy-space-5); padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--legacy-space-3) var(--legacy-space-5); margin: 0; }
.dashboard-status-rack { display: grid; grid-template-columns: minmax(270px, 1.45fr) repeat(3, minmax(180px, .9fr)) minmax(250px, 1.2fr); gap: var(--space-2); }
.dashboard-status-metric { display: grid; grid-template-columns: 98px minmax(0, 1fr); align-items: center; gap: var(--space-4); min-width: 0; min-height: 190px; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-radius: 0; color: var(--text); background: var(--panel); text-align: left; }
button.dashboard-status-metric { cursor: pointer; }
button.dashboard-status-metric:hover { border-color: currentColor; }
button.dashboard-status-metric:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
button.dashboard-status-metric[aria-pressed="true"] { outline: var(--bw-emphasis) solid currentColor; outline-offset: calc(-1 * var(--bw-emphasis)); }
.dashboard-status-metric-large { grid-template-columns: 154px minmax(0, 1fr); }
.dashboard-status-ring { position: relative; width: 98px; height: 98px; color: var(--donut-all); }
.dashboard-status-metric-large .dashboard-status-ring { width: 154px; height: 154px; }
.dashboard-status-ring svg { display: block; width: 100%; height: 100%; transform: rotate(-90deg); }
.dashboard-status-ring circle { fill: none; stroke-width: 12; }
.dashboard-status-metric-large .dashboard-status-ring circle { stroke-width: 10; }
.dashboard-status-ring-track { stroke: var(--donut-border); }
.dashboard-status-ring-progress { stroke: currentColor; }
.dashboard-status-ring > span { position: absolute; inset: 0; display: flex; align-items: baseline; justify-content: center; padding-top: 36px; color: var(--heading); font-variant-numeric: tabular-nums; }
.dashboard-status-metric-large .dashboard-status-ring > span { padding-top: 57px; }
.dashboard-status-ring > span strong { font: var(--fw-semibold) var(--donut-value-size) / 1 var(--font-donut-display); }
.dashboard-status-metric-large .dashboard-status-ring > span strong { font-size: calc(var(--donut-value-size) * 1.45); }
.dashboard-status-ring > span small { margin-left: var(--space-1); color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--fw-semibold); }
.dashboard-status-ring-deviation { color: var(--donut-error); }
.dashboard-status-ring-passed { color: var(--donut-passed); }
.dashboard-status-ring-not-run { color: var(--donut-deviation); }
.dashboard-status-copy { display: grid; gap: var(--space-3); min-width: 0; }
.dashboard-status-copy > span { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-status-copy > strong { color: var(--heading); font-size: calc(var(--ui-heading-size) * 1.35); font-variant-numeric: tabular-nums; }
.dashboard-status-copy > strong small, .dashboard-status-copy > small { color: var(--muted); font-size: var(--ui-body-size); font-weight: var(--fw-regular); line-height: 1.35; }
.dashboard-status-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(100px, .8fr); align-items: stretch; min-width: 0; min-height: 190px; padding: var(--space-4); border: var(--border-hair) solid var(--line); background: var(--panel); }
.dashboard-status-summary-stack { display: grid; align-content: center; padding-right: var(--space-4); border-right: var(--border-hair) solid var(--line-strong); }
.dashboard-status-summary-stack button { display: grid; gap: var(--space-2); min-height: 64px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-status-summary-stack button + button { border-top: var(--border-hair) solid var(--line); }
.dashboard-status-summary-stack button:hover { background: var(--panel-hover); }
.dashboard-status-summary-stack button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-status-summary-stack span, .dashboard-ioe-summary span { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-status-summary-stack strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-ioe-summary { display: grid; align-content: center; gap: var(--space-4); min-width: 0; padding: 0 0 0 var(--space-5); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.dashboard-ioe-summary:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-ioe-summary:disabled { cursor: default; }
.dashboard-ioe-summary strong { color: var(--accent); font: var(--fw-semibold) calc(var(--donut-value-size) * 1.45) / 1 var(--font-donut-display); font-variant-numeric: tabular-nums; }
.dashboard-distribution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.dashboard-lower-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(420px, 1fr); align-items: stretch; gap: var(--space-3); }
.dashboard-environment { min-width: 0; margin: 0; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-environment .datahub-section-heading h2, .dashboard-block h2 { margin: 0; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
.dashboard-coverage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: var(--space-4); padding-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--line); }
.dashboard-coverage-summary > span { display: grid; gap: var(--space-1); min-width: 0; padding: 0 var(--space-3); text-align: center; }
.dashboard-coverage-summary > span + span { border-left: var(--border-hair) solid var(--line-strong); }
.dashboard-coverage-summary strong { color: var(--heading); font-size: var(--ui-heading-size); font-variant-numeric: tabular-nums; }
.dashboard-coverage-summary small { color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3) var(--space-5); margin: 0; }
.dashboard-facts div { min-width: 0; }
.dashboard-facts dt { margin-bottom: var(--legacy-space-1); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts dd { margin: 0; color: var(--heading); font-size: var(--ui-body-size); overflow-wrap: anywhere; }
.dashboard-coverage { display: flex; flex-wrap: wrap; gap: var(--legacy-space-2); margin-top: var(--legacy-space-4); }
.dashboard-state { padding: var(--legacy-space-1) var(--legacy-space-2); border: var(--border-hair) solid var(--line); color: var(--muted); background: var(--panel-raised); font-size: var(--ui-label-size); }
.dashboard-state-observed { border-color: var(--legacy-accent); color: var(--text); }
.dashboard-state-license-restricted, .dashboard-state-access-denied, .dashboard-state-parse-failed { border-color: var(--severity-high); }
.dashboard-context-coverage { margin: var(--legacy-space-4) 0 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); align-items: start; gap: var(--legacy-space-5); }
.dashboard-block { padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-question { margin: 0 0 var(--legacy-space-4); color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-block { min-width: 0; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-distribution { display: grid; align-content: start; gap: var(--space-4); }
.dashboard-segmented-bar { display: flex; width: 100%; height: 28px; overflow: hidden; background: var(--surface-active); }
.dashboard-segment { flex: 0 0 var(--share); min-width: 2px; height: 100%; background: var(--severity, var(--viz-1)); }
.dashboard-segment + .dashboard-segment { border-left: var(--border-hair) solid var(--surface-panel); }
.dashboard-distribution-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.dashboard-legend-button { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; min-height: 52px; padding: var(--space-2); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-legend-button:hover { border-color: var(--line); background: var(--panel-hover); }
.dashboard-legend-button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-legend-swatch { flex: 0 0 10px; width: 10px; height: 10px; margin-top: 3px; background: var(--severity, var(--viz-1)); }
.dashboard-legend-copy { display: grid; gap: var(--space-1); min-width: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-legend-copy > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-legend-copy strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-legend-copy small { color: var(--muted); font-size: var(--ui-control-size); font-weight: var(--fw-regular); }
.dashboard-viz-1 { --severity: var(--viz-1); }
.dashboard-viz-2 { --severity: var(--viz-2); }
.dashboard-viz-3 { --severity: var(--viz-3); }
.dashboard-viz-4 { --severity: var(--viz-4); }
.dashboard-viz-5 { --severity: var(--viz-5); }
.dashboard-viz-6 { --severity: var(--viz-6); }
.dashboard-empty { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-bars { display: grid; gap: var(--border-hair); margin: 0; padding: 0; list-style: none; }
/* Zeile als Raster: Bezeichnung, Anteilsbalken, Zahl, Anteil. Die beiden
rechten Spalten sind fest, damit Zahlen ueber alle Zeilen fluchten. */
.dashboard-bar { display: grid; grid-template-columns: 132px minmax(0, 1fr) 62px 44px; align-items: center; gap: var(--legacy-space-3); width: 100%; min-height: var(--row-height); padding: 0 var(--legacy-space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-bar:hover { background: var(--panel-hover); }
.dashboard-bar:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-bar-label { min-width: 0; overflow: hidden; color: var(--text); font-size: var(--ui-control-size); text-overflow: ellipsis; white-space: nowrap; }
/* --share setzt die Komponente zur Laufzeit je Zeile. Der Vorgabewert steht
hier und nicht in der Tokendatei: es ist ein Messwert dieser Darstellung,
keine Rolle der Designsprache. Ohne ihn bliebe ein Balken ohne Messwert
ungerendert statt leer. */
/* Die Spur nutzt --surface-active statt --surface-inset: letzteres ist im
hellen Skin dieselbe Flaeche wie das Panel, der ungefuellte Anteil des
Balkens waere dort unsichtbar gewesen. */
.dashboard-bar-track { --share: 0%; display: block; width: 100%; height: 10px; background: var(--surface-active); }
/* Ohne Severity-Klasse faellt der Balken auf den neutralen Ton zurueck - so
traegt der Ergebnisstatus keine Schwereaussage, die er nicht macht. */
.dashboard-bar-fill { display: block; width: var(--share); height: 100%; background: var(--severity, var(--legacy-accent)); }
.dashboard-bar-count { color: var(--heading); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-bar-share { color: var(--muted); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-progress { display: grid; gap: var(--legacy-space-2); }
.dashboard-progress-legend { color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-risk-profile { display: grid; align-content: start; }
.dashboard-risk-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 32px; }
.dashboard-risk-tool { min-height: 30px; max-width: min(100%, 320px); padding: var(--space-1) var(--space-2); border: var(--border-hair) solid transparent; border-radius: 0; color: var(--heading); background-color: transparent; font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; cursor: pointer; }
.dashboard-risk-tool:hover { border-color: var(--line-strong); background-color: var(--panel-hover); }
.dashboard-risk-tool:focus-visible { border-color: var(--focus-ring); outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-tool option { color: var(--text); background-color: var(--surface-input); text-transform: none; }
.dashboard-marked-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-5); min-width: 240px; padding: var(--space-2) var(--space-3); border: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-marked-summary strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-body { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(280px, 1.2fr); min-height: 190px; margin-top: var(--space-4); }
.dashboard-risk-categories, .dashboard-risk-legend { margin: 0; padding: 0; list-style: none; }
.dashboard-risk-categories { display: grid; align-content: center; padding-right: var(--space-5); border-right: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-categories li + li { border-top: var(--border-hair) solid var(--line); }
.dashboard-risk-categories button, .dashboard-risk-legend button { display: grid; align-items: center; width: 100%; min-height: 42px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-risk-categories button { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-categories button:hover, .dashboard-risk-legend button:hover { background: var(--panel-hover); }
.dashboard-risk-categories button:focus-visible, .dashboard-risk-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-categories span, .dashboard-risk-legend span { min-width: 0; overflow: hidden; font-size: var(--ui-body-size); text-overflow: ellipsis; white-space: nowrap; }
.dashboard-risk-categories strong, .dashboard-risk-legend strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-severity { display: flex; align-items: center; justify-content: center; gap: var(--space-5); min-width: 0; padding-left: var(--space-5); }
.dashboard-risk-donut { flex: 0 0 150px; width: 150px; height: 150px; overflow: visible; transform: rotate(-90deg); }
.dashboard-risk-donut circle { fill: none; stroke-width: 13; }
.dashboard-risk-donut-track { stroke: var(--surface-active); }
.dashboard-risk-donut-segment { stroke: var(--severity, var(--info)); }
.dashboard-risk-legend { flex: 1 1 auto; min-width: 120px; }
.dashboard-risk-legend button { grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-legend i { width: 12px; height: 12px; background: var(--severity, var(--info)); }
@media (max-width: 1200px) {
.dashboard-status-rack { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-status-summary { grid-column: 1 / -1; }
.dashboard-distribution-grid, .dashboard-lower-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.dashboard-status-rack { grid-template-columns: 1fr; }
.dashboard-status-summary { grid-column: auto; }
.dashboard-coverage-summary, .dashboard-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-risk-body { grid-template-columns: 1fr; }
.dashboard-risk-categories { padding-right: 0; padding-bottom: var(--space-4); border-right: 0; border-bottom: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
}
/* DB-Struktur panel */
.db-structure-page { width: 100%; max-width: none; }

View File

@ -40,6 +40,15 @@ describe('DonutMetricCard binding design contract', () => {
expect(zero).not.toContain('<animate');
});
it('supports a single suffixed score without a duplicate percentage label', () => {
const html = renderToStaticMarkup(
<DonutMetricCard label="Compliance Score" value={36} valueSuffix="%" showPercentage={false} percentage={36} tone="all" />
);
expect(html).toContain('<strong>36%</strong>');
expect(html).not.toContain('<span>36%</span>');
});
it('becomes an accessible active filter control when clickable', () => {
const html = renderToStaticMarkup(
<DonutMetricCard

View File

@ -28,7 +28,7 @@ describe('processing compatibility contracts', () => {
it('separates PingCastle presentation from canonical mapping semantics', () => {
const old = file('old', 'pingcastle', '1.1.0', '1.1.0');
const contextless = file('contextless', 'pingcastle', '1.2.0', '1.1.0');
const current = file('current', 'pingcastle', '1.3.0', '1.1.0');
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
expect(resolvedCompatibilityKey('pingcastle', old.processedWith)).toBe('pingcastle-canonical-v1.1');
expect(resolvedCompatibilityKey('pingcastle', contextless.processedWith)).toBe('pingcastle-canonical-v1.1');
@ -41,7 +41,7 @@ describe('processing compatibility contracts', () => {
it('blocks semantically incompatible versions in one scope', () => {
const legacy = file('legacy', 'pingcastle', '1.0.0', '1.0.0');
const current = file('current', 'pingcastle', '1.3.0', '1.1.0');
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
expect(processingReadiness('pingcastle', legacy.processedWith)).toBe('incompatible');
expect(processingScopeIssues([legacy, current])).toMatchObject([{
toolId: 'pingcastle', code: 'incompatible-processing-contracts'

View File

@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { dashboardExplicitIoeCount, dashboardExplicitIoeRecordIds, dashboardPingCastleRiskCategories, dashboardRiskTools, dashboardScanSourceLabel } from '../../src/features/dashboard/DashboardPanel';
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
describe('dashboard reference layout', () => {
it('uses the reference metric strip for compliance, primary statuses and the compact summary', () => {
expect(component).toContain('className="dashboard-status-rack"');
expect(component).toContain('label="Compliance Score"');
expect(component).toContain('<DashboardStatusMetric label="Deviation"');
expect(component).toContain('<DashboardStatusMetric label="Passed"');
expect(component).toContain('<DashboardStatusMetric label="Not Run"');
expect(component).toContain('className="dashboard-status-summary"');
expect(component).toContain('<span>Not Applicable</span>');
expect(component).toContain('<span>Error</span>');
expect(component).toContain('className="dashboard-ioe-summary" type="button"');
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
expect(styles).toMatch(/\.dashboard-status-rack\s*\{[^}]*repeat\(3,/s);
expect(styles).toContain('.dashboard-status-ring-track { stroke: var(--donut-border); }');
expect(styles).toContain('button.dashboard-status-metric:hover { border-color: currentColor; }');
});
it('uses paired segmented distributions and a compact lower dashboard row', () => {
expect(component).toContain('heading="Severity distribution"');
expect(component).toContain('heading="Scan source distribution"');
expect(component).toContain('className="dashboard-lower-grid"');
expect(styles).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/s);
expect(styles).toContain('.dashboard-segmented-bar');
});
it('matches the reference structure for directory coverage and risk profile', () => {
expect(component).toContain('className="dashboard-coverage-summary"');
expect(component).toContain('<small>typed observations</small>');
expect(component).toContain('className="dashboard-block dashboard-risk-profile"');
expect(component).toContain('className="dashboard-marked-summary"');
expect(component).toContain('className="dashboard-risk-tool"');
expect(component).toContain('<option value={fullRiskProfileId}>Full Risk Profile</option>');
expect(component).toContain('className="dashboard-risk-donut"');
expect(component).toContain('categories.slice(0, 4)');
expect(component).toContain('matchesDynamicFacets(record, [sourceFacet]');
expect(component).not.toContain('dashboard-review-block');
expect(styles).toMatch(/\.dashboard-facts\s*\{[^}]*grid-template-columns:\s*repeat\(4,/s);
expect(styles).toMatch(/\.dashboard-risk-body\s*\{[^}]*grid-template-columns:/s);
});
it('removes only a trailing scan timestamp from source labels', () => {
expect(dashboardScanSourceLabel('PurpleKnight · 2026-08-22T08:15:30.000Z')).toBe('PurpleKnight');
expect(dashboardScanSourceLabel('CIS-CAT Pro · 2026-08-22')).toBe('CIS-CAT Pro');
expect(dashboardScanSourceLabel('Scanner · Production')).toBe('Scanner · Production');
});
it('groups scan runs into one selectable risk profile per tool', () => {
expect(dashboardRiskTools([
{ value: 'pingcastle-a', label: 'PingCastle · 2026-08-22T08:15:30.000Z', count: 20 },
{ value: 'pingcastle-b', label: 'PingCastle · 2026-08-23T08:15:30.000Z', count: 24 },
{ value: 'purpleknight-a', label: 'PurpleKnight · 2026-08-23T09:00:00.000Z', count: 30 }
])).toEqual([
{ id: 'pingcastle', label: 'PingCastle', sourceValues: ['pingcastle-a', 'pingcastle-b'], count: 44 },
{ id: 'purpleknight', label: 'PurpleKnight', sourceValues: ['purpleknight-a'], count: 30 }
]);
});
it('keeps the complete PingCastle risk catalog including zero scores', () => {
expect(dashboardPingCastleRiskCategories([
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
{ property: 'risk.stale_objects', normalizedValue: 10, originalValue: '10' },
{ property: 'risk.privileged_accounts', normalizedValue: 7, originalValue: '7' },
{ property: 'risk.trusts', normalizedValue: 0, originalValue: '0' }
], [])).toEqual([
{ value: 'anomalies', label: 'Anomalies', count: 20 },
{ value: 'stale objects', label: 'Stale Objects', count: 10 },
{ value: 'privileged accounts', label: 'Privileged Accounts', count: 7 },
{ value: 'trusts', label: 'Trusts', count: 0 }
]);
});
it('counts only explicit IOE-found source results for the top summary', () => {
const record = (id: string, value: string): FindingRecord => ({
id, index: 0, title: id,
fields: [{ index: 0, label: 'Result', value }],
filled: [], meta: []
});
expect(dashboardExplicitIoeCount([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed'),
record('pass', 'Passed')
])).toBe(2);
expect(dashboardExplicitIoeRecordIds([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed')
])).toEqual(['ioe', 'ioes']);
});
});

View File

@ -43,7 +43,7 @@ describe('Datahub client processing readiness', () => {
file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({
state: 'update',
explanation: 'ASSESSOR profile update: 1.1.0 → 1.3.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
explanation: 'ASSESSOR profile update: 1.1.0 → 1.4.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
});
});
@ -52,7 +52,7 @@ describe('Datahub client processing readiness', () => {
file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({
state: 'update',
explanation: 'ASSESSOR profile update: 1.2.0 → 1.3.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
explanation: 'ASSESSOR profile update: 1.2.0 → 1.4.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
});
});

View File

@ -16,15 +16,39 @@ describe('PingCastle native finding extraction', () => {
it('captures non-finding environment sections, typed facts and explicit absence in the canonical model', async () => {
const environmentSections = [
'<p>Date: 2026-08-20 - Engine version: 3.4.0</p>',
'<div id="sectionDomainInformation"><h2>Domain Information</h2>',
'<p>No Entra ID configuration has been found in this domain.</p>',
'<div id="sectionDomainInformation"><h1>Domain Information</h1>',
'<table><caption>Directory</caption><thead><tr>',
'<th>Domain</th><th>Domain Functional Level</th><th>Forest Functional Level</th><th>Schema Version</th><th>Recycle Bin Enabled</th><th>Number of DC</th>',
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table></div>',
'<div id="sectionPKI"><h2>Public Key Infrastructure</h2><table><caption>Certificate Authorities</caption>',
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table>',
'<h2>Entra ID Configuration</h2><p>No Entra ID configuration has been found in this domain.</p></div>',
'<div id="sectionPKI"><h1>Public Key Infrastructure</h1><h2>Certificates</h2><table><caption>Certificate Authorities</caption>',
'<thead><tr><th>Certificate Authority</th></tr></thead><tbody></tbody></table></div>',
'<div id="sectionInfrastructure"><h2>Infrastructure</h2><p>Number of WEF configuration found: 0</p></div>',
'<div id="sectionLAPS"><h2>LAPS</h2><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
'<div id="sectionInfrastructure"><h1>Infrastructure</h1><h2>Microsoft Entra Connect settings</h2>',
'<table><thead><tr><th>Service</th></tr></thead><tbody><tr><td>Synthetic Infrastructure</td></tr></tbody></table></div>',
'<div id="sectionAnomalyFacts"><p>Number of WEF configuration found: 0</p><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
'<div id="sectionUserInformation"><h1>User Information</h1><h2>Honey Pot</h2>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Creation</th></tr></thead>',
'<tbody><tr><td>Synthetic Honeypot</td><td>2026-08-20</td></tr></tbody></table><h2>Account analysis</h2>',
'<table><thead><tr><th>Nb User Accounts</th><th>Nb Enabled</th></tr></thead>',
'<tbody><tr><td>12</td><td>10</td></tr></tbody></table><h2>Password Age Distribution</h2>',
'<div id="sectionNeverExpiresUser"><h3>Objects with a password which never expires</h3>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Last logon</th></tr></thead>',
'<tbody><tr><td>Synthetic User</td><td>Never</td></tr></tbody></table></div></div>',
'<div id="sectionComputerInformation"><h1>Computer Information</h1><h2>Operating Systems</h2>',
'<table aria-label="Computer information list"><thead><tr><th>Name</th><th>Operating system</th></tr></thead>',
'<tbody><tr><td>Synthetic Computer</td><td>Synthetic OS</td></tr></tbody></table></div>',
'<div id="sectionAdminGroups"><h1>Admin Groups</h1><h2>Groups</h2><table><thead><tr><th>Group</th></tr></thead>',
'<tbody><tr><td>Synthetic Admin Group</td></tr></tbody></table></div>',
'<div id="sectionControlPathsAnalysis"><h1>Control Paths Analysis</h1><h2>Indirect links</h2><table><thead><tr><th>Path</th></tr></thead>',
'<tbody><tr><td>Synthetic Control Path</td></tr></tbody></table></div>',
'<div id="sectionAnomalies"><h1>Anomalies</h1><h2>AdminSDHolder (detect temporary elevated accounts)</h2><table><thead><tr><th>Item</th></tr></thead>',
'<tbody><tr><td>Synthetic Anomaly</td></tr></tbody></table></div>',
'<div id="sectionPasswordPolicies"><h1>Password Policies</h1><h2>Password policies</h2><table><thead><tr><th>Policy</th></tr></thead>',
'<tbody><tr><td>Synthetic Password Policy</td></tr></tbody></table></div>',
'<div id="sectionGPO"><h1>GPO</h1><h2>Security settings</h2><table><thead><tr><th>GPO</th></tr></thead>',
'<tbody><tr><td>Synthetic GPO</td></tr></tbody></table></div>',
'<div id="sectionFutureInventory"><h2>Future Inventory</h2><table><thead><tr><th>Value</th></tr></thead>',
'<tbody><tr><td>Synthetic unknown value</td></tr></tbody></table></div>',
'<div id="sectionMaturity"><h2>Maturity</h2><p>This section is reserved for customers who have purchased a license.</p></div>'
].join('');
const content = pingCastleHtml.replace('</main>', environmentSections + '</main>');
@ -36,15 +60,26 @@ describe('PingCastle native finding extraction', () => {
expect.objectContaining({ sectionKey: 'sectionMaturity', state: 'license-restricted' })
]));
expect(dataset.extraction?.environmentObservations).toEqual(expect.arrayContaining([
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example' }),
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example', subject: expect.objectContaining({ type: 'domain-information' }) }),
expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'forest.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'domain.recycle_bin_enabled', normalizedValue: true }),
expect.objectContaining({ property: 'domain.controller_count', normalizedValue: 2 }),
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent' }),
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0 }),
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent' }),
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' })
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'domain-information.entra-id-configuration' }) }),
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0, subject: expect.objectContaining({ type: 'anomalies.windows-event-forwarding-wef' }) }),
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'anomalies.laps' }) }),
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Honeypot', subject: expect.objectContaining({ type: 'user-information.honey-pot' }) }),
expect.objectContaining({ label: 'Nb User Accounts', normalizedValue: 12, subject: expect.objectContaining({ type: 'user-information.account-analysis' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic User', subject: expect.objectContaining({ type: 'user-information.password-age-distribution' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Computer', subject: expect.objectContaining({ type: 'computer-information.operating-systems' }) }),
expect.objectContaining({ originalValue: 'Synthetic Admin Group', subject: expect.objectContaining({ type: 'admin-groups.groups' }) }),
expect.objectContaining({ originalValue: 'Synthetic Control Path', subject: expect.objectContaining({ type: 'control-paths-analysis.indirect-links' }) }),
expect.objectContaining({ originalValue: 'Synthetic Infrastructure', subject: expect.objectContaining({ type: 'infrastructure.microsoft-entra-connect-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic Anomaly', subject: expect.objectContaining({ type: 'anomalies.adminsdholder-detect-temporary-elevated-accounts' }) }),
expect.objectContaining({ originalValue: 'Synthetic Password Policy', subject: expect.objectContaining({ type: 'password-policies.password-policies' }) }),
expect.objectContaining({ originalValue: 'Synthetic GPO', subject: expect.objectContaining({ type: 'gpo.security-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic unknown value', subject: expect.objectContaining({ type: 'pingcastle-table-row' }) })
]));
const preview = previewEntryFromDataset(dataset, {
@ -55,12 +90,44 @@ describe('PingCastle native finding extraction', () => {
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
expect(model.importFiles[0].processedWith).toMatchObject({
profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0',
profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
});
expect(model.scanRuns[0].tool.version).toBe('3.4.0');
});
it('preserves every visible PingCastle environment subsection as a hierarchical subject type', async () => {
const definitions: Array<[string, string, Array<[string, string]>]> = [
['sectionDomainInformation', 'Domain Information', [['Entra ID Configuration', 'domain-information.entra-id-configuration']]],
['sectionUserInformation', 'User Information', [['Honey Pot', 'user-information.honey-pot'], ['Account analysis', 'user-information.account-analysis'], ['Password Age Distribution', 'user-information.password-age-distribution']]],
['sectionComputerInformation', 'Computer Information', [['Account analysis', 'computer-information.account-analysis'], ['Operating Systems', 'computer-information.operating-systems'], ['Domain controllers', 'computer-information.domain-controllers'], ['LAPS Analysis', 'computer-information.laps-analysis']]],
['sectionAdminGroups', 'Admin Groups', [['Groups', 'admin-groups.groups'], ['Last Logon Distribution', 'admin-groups.last-logon-distribution'], ['Delegations', 'admin-groups.delegations']]],
['sectionControlPathsAnalysis', 'Control Paths Analysis', [['Foreign domain involved', 'control-paths-analysis.foreign-domain-involved'], ['Indirect links', 'control-paths-analysis.indirect-links'], ['Admin groups', 'control-paths-analysis.admin-groups'], ['Critical Infrastructure', 'control-paths-analysis.critical-infrastructure']]],
['sectionTrustsdetails', 'Trusts Details', [['Discovered Domains', 'trusts-details.discovered-domains'], ['Reachable Domains', 'trusts-details.reachable-domains']]],
['sectionPKI', 'PKI', [['Certificates', 'pki.certificates'], ['Certificate Templates', 'pki.certificate-templates'], ['Domain Controller Certificate', 'pki.domain-controller-certificate']]],
['sectionInfrastructure', 'Infrastructure', [['Microsoft Entra Connect settings', 'infrastructure.microsoft-entra-connect-settings'], ['WSUS settings', 'infrastructure.wsus-settings'], ['Exchange settings', 'infrastructure.exchange-settings'], ['SCCM settings', 'infrastructure.sccm-settings'], ['Service Connection Points', 'infrastructure.service-connection-points'], ['Replacement of RC4 by AES in kerberos', 'infrastructure.replacement-of-rc4-by-aes-in-kerberos']]],
['sectionAnomalies', 'Anomalies', [['Backup', 'anomalies.backup'], ['LAPS', 'anomalies.laps'], ['Windows Event Forwarding (WEF)', 'anomalies.windows-event-forwarding-wef'], ['krbtgt (Used for Golden ticket attacks)', 'anomalies.krbtgt-used-for-golden-ticket-attacks'], ['AdminSDHolder (detect temporary elevated accounts)', 'anomalies.adminsdholder-detect-temporary-elevated-accounts'], ['Unix Passwords', 'anomalies.unix-passwords'], ['Java code reference', 'anomalies.java-code-reference'], ['Logon scripts', 'anomalies.logon-scripts'], ['Advanced', 'anomalies.advanced']]],
['sectionPasswordPolicies', 'Password Policies', [['Password policies', 'password-policies.password-policies'], ['Screensaver policies', 'password-policies.screensaver-policies']]],
['sectionGPO', 'GPO', [['Obfuscated Passwords', 'gpo.obfuscated-passwords'], ['Restricted Groups', 'gpo.restricted-groups'], ['Security settings', 'gpo.security-settings'], ['Audit settings', 'gpo.audit-settings'], ['Privileges', 'gpo.privileges'], ['Login', 'gpo.login'], ['GPO Login script', 'gpo.gpo-login-script'], ['GPO Deployed Files', 'gpo.gpo-deployed-files'], ['Folder Options', 'gpo.folder-options'], ['Microsoft Defender ASR (attack surface reduction)', 'gpo.microsoft-defender-asr-attack-surface-reduction'], ['Firewall configuration', 'gpo.firewall-configuration']]]
];
const sections = definitions.map(([sectionKey, sectionLabel, subsections]) =>
`<div id="${sectionKey}"><h1>${sectionLabel}</h1>${subsections.map(([label]) =>
`<h2>${label}</h2><table aria-label="${label} list"><thead><tr><th>Value</th></tr></thead><tbody><tr><td>Synthetic value</td></tr></tbody></table>`
).join('')}</div>`
).join('');
const dataset = await importPingCastle(
pingCastleHtml.replace('</main>', sections + '</main>'),
'ad_hc_subsections.synthetic.html',
'text/html'
);
const subjectTypes = new Set((dataset.extraction?.environmentObservations ?? []).map(observation => observation.subject.type));
definitions.flatMap(([, , subsections]) => subsections).forEach(([, expectedType]) => {
expect(subjectTypes.has(expectedType), expectedType).toBe(true);
});
expect([...subjectTypes].some(type => /rule-details/.test(type))).toBe(false);
});
it('maps the same matched rule identity from HTML and XML while preserving format-native provenance', async () => {
const datasets = await Promise.all([
importPingCastle(pingCastleHtml, 'ad_hc_synthetic.example.html', 'text/html'),

View File

@ -207,7 +207,7 @@ describe('LocalAssessorRepository contract', () => {
}];
data.environmentObservations = [{
version: 1, id: 'observation-run-1', scanRunId: run.id, importFileId: data.importFiles[0].id,
tool: run.tool, subject: { type: 'active-directory-domain', identity: 'synthetic.example' },
tool: run.tool, subject: { type: 'domain-information', identity: 'synthetic.example' },
property: 'domain.functional_level', label: 'Domain functional level', originalValue: '2016', normalizedValue: 2016,
valueType: 'number', state: 'observed', sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' },
mappingVersion: '2.0.0', attributes: {}

View File

@ -261,8 +261,8 @@ describe('Unified result read model', () => {
format: 'HTML',
fileName: 'synthetic-pingcastle.html',
processedWith: {
parserId: 'html-report', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion: '1.3.0',
mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
parserId: 'html-report', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion: '1.4.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
}
};
@ -273,11 +273,11 @@ describe('Unified result read model', () => {
importFiles: [importFile],
environmentObservations: [{
version: 1, id: 'environment-dfl', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool,
subject: { type: 'active-directory-domain', identity: 'synthetic.example' },
subject: { type: 'domain-information', identity: 'synthetic.example' },
property: 'domain.functional_level', label: 'Domain functional level',
originalValue: 'Windows Server 2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' },
mappingVersion: '1.1.0+context-1.0.0', attributes: { extractionMethod: 'synthetic-fixture' }
mappingVersion: '1.2.0+context-1.0.0', attributes: { extractionMethod: 'synthetic-fixture' }
}]
});
await session.commit();
@ -286,7 +286,7 @@ describe('Unified result read model', () => {
projectId: project.id,
sourceKind: ['environment-observation'],
environmentProperty: ['domain.functional_level'],
environmentSubjectType: ['active-directory-domain'],
environmentSubjectType: ['domain-information'],
environmentState: ['observed'],
search: '2016'
});
@ -296,13 +296,13 @@ describe('Unified result read model', () => {
resultProjectionId: 'environment-observation:environment-dfl',
sourceKind: 'environment-observation',
sourceTool: 'pingcastle',
sourceProfileVersion: '1.3.0',
mappingRuleVersion: '1.1.0+context-1.0.0',
sourceProfileVersion: '1.4.0',
mappingRuleVersion: '1.2.0+context-1.0.0',
processingStatus: 'current',
importArtifactId: importFile.id,
sourceLocator: { objectPath: 'html/sections/domain/table/0' },
environmentProperty: 'domain.functional_level',
environmentSubjectType: 'active-directory-domain',
environmentSubjectType: 'domain-information',
environmentSubjectIdentity: 'synthetic.example',
environmentOriginalValue: 'Windows Server 2016',
environmentNormalizedValue: 2016,
@ -327,11 +327,11 @@ describe('Unified result read model', () => {
expect(facets.facets.find(facet => facet.id === 'environmentState')?.values)
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
.toContainEqual(expect.objectContaining({ value: 'active-directory-domain', count: 1 }));
.toContainEqual(expect.objectContaining({ value: 'domain-information', count: 1 }));
const categories = await repository.listResultCategories({ sourceKind: ['environment-observation'] });
expect(categories).toEqual(expect.arrayContaining([
expect.objectContaining({ namespace: 'environment-subject', categoryCode: 'active-directory-domain' }),
expect.objectContaining({ namespace: 'environment-subject', categoryCode: 'domain-information' }),
expect.objectContaining({ namespace: 'environment-property', categoryCode: 'domain' })
]));
});

View File

@ -90,7 +90,7 @@ describe('local ASSESSOR HTTP server', () => {
}],
environmentObservations: [{
version: 1, id: 'observation-1', scanRunId: scanRun.id, importFileId: 'file-1', tool: scanRun.tool,
subject: { type: 'active-directory-domain' }, property: 'domain.functional_level', label: 'Domain functional level',
subject: { type: 'domain-information' }, property: 'domain.functional_level', label: 'Domain functional level',
originalValue: '2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' }, mappingVersion: '2.0.0', attributes: {}
}]