Bug Fix Result DB

(cherry picked from commit 83284c2183)

Conflicts resolved against AI-integration: kept CVE enrichment display
fields, regulatory mapping, Defender for IoT scoring aliases and the
extracted public-security-topics module alongside the Result DB,
source-rule-id, canonical-title and Maester changes. Updated the
zero-inference test to the new Purple Knight AD fact labels.
This commit is contained in:
Michael 2026-09-23 13:53:40 +02:00 committed by Binh
parent b60e9dc8eb
commit 976dcffaa4
60 changed files with 2910 additions and 233 deletions

View File

@ -9,6 +9,13 @@
- After a material, verified implementation step, update the plan's status, decisions, risks, and change log in the same change. Do not rewrite historical decisions silently. - After a material, verified implementation step, update the plan's status, decisions, risks, and change log in the same change. Do not rewrite historical decisions silently.
- If the requested implementation conflicts with the plan, explain the conflict and update the plan only with the user's agreement or when the request explicitly supersedes it. - If the requested implementation conflicts with the plan, explain the conflict and update the plan only with the user's agreement or when the request explicitly supersedes it.
## ASSESSOR product version
- `package.json` is the single source of truth for the visible ASSESSOR product version. Header, About dialog and backend health response must continue to read that shared value; keep the root `package-lock.json` entry and version contract tests synchronized.
- Reassess the product version after every material, verified implementation batch. Increment the patch version for backward-compatible corrections and refinements, the minor version for a backward-compatible capability release, and the major version only for an intentionally breaking product or deployment contract.
- Do not increment the product version for documentation-only edits, test-only maintenance, unfinished work or every individual commit. Batch related work into one release version and record the decision in `docs/IMPLEMENTATION_PLAN.md`.
- A version change is complete only after the product-version UI/API tests and both production builds pass. Historical reports retain the version they documented at creation time and must not be rewritten.
## Binding agreements and rule conflicts ## Binding agreements and rule conflicts
- Treat documented user decisions, this file, and the implementation plan as binding acceptance criteria. Before changing an affected behavior, identify and preserve every relevant agreement; later work must not silently weaken, replace, or bypass it. - Treat documented user decisions, this file, and the implementation plan as binding acceptance criteria. Before changing an affected behavior, identify and preserve every relevant agreement; later work must not silently weaken, replace, or bypass it.

View File

@ -57,6 +57,37 @@ Defender-CVE-Beschreibung nur im Detail 2026-09-11 (Phase 7, Nutzerpraezisierung
Defender-for-IoT-Inline-Drilldowns 2026-09-11 (Phase 7, Nutzerkorrektur): CVE-, Device- und Site-Details erscheinen jetzt unmittelbar unter der ausgewaehlten Tabellenzeile statt gesammelt am Ende der jeweiligen Liste. Die vorhandenen Bezeichnungsbuttons verwenden ein einheitliches Auf-/Zuklappdreieck, `aria-expanded` und stabile `aria-controls`-Ziele; ein erneuter Klick schliesst das Detail, eine andere Auswahl verschiebt das einzige offene Detail. Die ausgewaehlte Zeile nutzt die gemeinsame gelbe Markierung, waehrend der Tastaturfokus blau bleibt. Verschachtelte Evidenztabellen bleiben innerhalb ihrer Detailzeile horizontal scrollbar und werden weder abgeschnitten noch verkuerzt. Synthetische Interaktionstests pruefen Oeffnen, Verschieben, Schliessen, unmittelbare Zeilennachbarschaft und Zugaenglichkeitszustand in allen drei Ansichten. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert. Defender-for-IoT-Inline-Drilldowns 2026-09-11 (Phase 7, Nutzerkorrektur): CVE-, Device- und Site-Details erscheinen jetzt unmittelbar unter der ausgewaehlten Tabellenzeile statt gesammelt am Ende der jeweiligen Liste. Die vorhandenen Bezeichnungsbuttons verwenden ein einheitliches Auf-/Zuklappdreieck, `aria-expanded` und stabile `aria-controls`-Ziele; ein erneuter Klick schliesst das Detail, eine andere Auswahl verschiebt das einzige offene Detail. Die ausgewaehlte Zeile nutzt die gemeinsame gelbe Markierung, waehrend der Tastaturfokus blau bleibt. Verschachtelte Evidenztabellen bleiben innerhalb ihrer Detailzeile horizontal scrollbar und werden weder abgeschnitten noch verkuerzt. Synthetische Interaktionstests pruefen Oeffnen, Verschieben, Schliessen, unmittelbare Zeilennachbarschaft und Zugaenglichkeitszustand in allen drei Ansichten. Verifikation: 1.243 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Reine Darstellungsaenderung; keine Datenmutation, API-, Persistenz-, Importer-, Profil-, Scoring- oder Reimportaenderung. Phasenstatus unveraendert.
Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7, Nutzerpraezisierung): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit unveraenderten CVE-, Device- und Site-Ansichten. Die Registerkarte verwendet jetzt denselben themenabhaengigen Client-Info-Canvas, dieselbe Client-/Assessment-/Source-Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich. Verifikation: 1.239 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert. Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7, Nutzerpraezisierung): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit unveraenderten CVE-, Device- und Site-Ansichten. Die Registerkarte verwendet jetzt denselben themenabhaengigen Client-Info-Canvas, dieselbe Client-/Assessment-/Source-Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich. Verifikation: 1.239 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only- und Designpruefung sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert.
Verifikation 2.5.12: 69 fokussierte Tests und beide Produktionsbuilds bestanden; die Gesamtsuite vor dem zusaetzlichen Routentest meldete 1.451 bestanden, 13 optionale Skips und dieselben fuenf bereits vorbestehenden fachfremden Vertragsfehler. Local-only-Sicherheitspruefung und Designsprachenpruefung bestanden. Laufendes lokales Backend auf Port 4173 meldet 2.5.12 mit PostgreSQL, App auf Port 5173 antwortet mit HTTP 200. Kein Zugriff auf den gemeldeten Kundenscope; dessen tatsaechliche Ansicht bleibt vom Nutzer zu pruefen.
PingCastle-Titel-/ID-Korrektur 2026-09-23 (Phase 7/13C mit Disclosure-Grenze aus Phase 12/13B, Nutzerregression): Die vorherige 2.5.11-Abnahme erfasste nur unterschiedliche Titel innerhalb desselben kanonischen Occurrence; der gemeldete HTML-/XML-Zweiberichtsfall blieb dadurch falsch. Work gleicht nun ausschliesslich PingCastle-Vorkommnisse mit derselben Projekt-, Berichtsdatum-, Dateistamm- und nativen Regel-ID ab und bevorzugt den eindeutigen fachlichen Titel des zugehoerigen XML-/HTML-Berichts. Bei widerspruechlichen gleichwertigen Titeln wird nicht geraten; der native HTML-Checktitel bleibt als `Native source heading` erhalten. Die Result DB gibt oeffentlich dokumentierte PingCastle-Regel-IDs anhand des erweiterten offiziellen Katalogs frei und bewahrt deren native Schreibweise (`S-oldNtlm`); unbekannte IDs bleiben gesperrt. Disclosure Policy 2.5.0, Patchversion 2.5.12. Keine Kunden-/Bestandsdaten, Migration oder Reimporte veraendert; Verifikation ausschliesslich synthetisch; auch Dummy wurde in diesem Schritt nicht gelesen.
Gemeinsame Result-DB-/Work-Ueberschrift 2026-09-23 (Phase 7/13C, Nutzerwunsch, umgesetzt und verifiziert): Work verwendet fuer Scannerfindings nun denselben kanonischen Definitionstitel wie die Result DB, statt die Ueberschrift erneut aus dem vorkommnisspezifischen nativen `Title`-Feld aufzubauen. Dadurch erscheint insbesondere bei verschiedenen PingCastle-Berichtsrepraesentationen die fachlich besser zuordenbare Result-DB-Ueberschrift einheitlich in Work-Aggregat, Quellansicht, Kartenliste und Finding Reader. Ein abweichender nativer Quelltitel bleibt in der reinen Leseprojektion als `Native source heading` sichtbar; Originalfelder, Provenienz, Identitaet und Persistenz bleiben unveraendert. Der konkrete Unterschied zwischen einem regelorientierten Checktitel und einem anzahlbezogenen kanonischen Titel ist mit ausschliesslich synthetischen Daten als Verhaltensregression abgesichert. 42 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestanden. Gesamtsuite: 1.448 bestanden, 13 optionale Skips und die fuenf bekannten fachfremden Vertragsfehler. Keine Bestandsdaten-, Import-, Persistenz- oder Reimportaenderung. Patchversion 2.5.11.
LLM-Result-DB-GPO-Quellenentkopplung 2026-09-23 (Phase 12/13B, Nutzerpraezisierung, umgesetzt und verifiziert): Disclosure Policy 2.4.0 behaelt die vollstaendige technische GPO-Abweichung mit Titel, kontrollierter Source Rule ID, Policy-Pfad/Value Name, Operation, Typ, Ist-/Sollwert, Vergleich, Status, oeffentlicher Baseline, technischer Beschreibung/Empfehlung, belegten Diagnosen, Aussagegrenze und Verarbeitungsversionen. Gleichzeitig entfallen `policyIdentityKey`, interne Baseline-IDs, automatisch erzeugte `gpo-policy-identity-*`-Hashes sowie jede Projekt-, Domain-, GPO-, Backup-, Datei-, Locator-, Zeit- und Reviewidentitaet. Wohlbekannte oeffentliche Principals bleiben lesbar; Domain-SIDs werden zu `S-1-5-21-[DOMAIN]-<RID>`, bekannte Domainrollen zu `[DOMAIN]\\<Rolle>` und sonstige konkrete Principals zu stabilen resultlokalen `[DOMAIN_PRINCIPAL_n]`. Dieselbe Reduktion gilt rekursiv fuer Baselinevarianten. Work und GPO Analysis behalten unveraendert alle Originale. Die konkrete Dummy-GPO-Struktur ist als synthetischer Disclosure-, Result-Row- und JSONL-Leckagetest abgebildet. 70 fokussierte Tests, Produktversionsvertrag, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestanden; App und PostgreSQL-Backend liefern 2.5.10. Keine Bestandsdaten-, Persistenz-, Import- oder Reimportaenderung. Patchversion 2.5.10.
Generische Security-Domain-Abdeckung und robuste Work-Suche 2026-09-23 (Phase 7/13C, Nutzerkorrekturen, umgesetzt und verifiziert): `Unclassified` ist ausschliesslich der letzte ehrliche Restfall, wenn keine belastbare Evidenz existiert oder eine Zuordnung mehrere Domains gleich plausibel machen und damit erfunden werden muesste. Die gemeinsame Klassifikation wertet zuerst Titel, beschreibende Kernfelder, Kategorien und `Source Rule ID` aus. Wenn diese keine Domain liefern, dienen technische Erklaerung, Rationale, Empfehlung, Remediation, Loesung, Impact und Details als zweite Evidenzstufe; diese darf nur bei genau einer passenden Domain zuordnen. `Print Spooler` beziehungsweise der eindeutige Token `Spooler` wird dabei werkzeugunabhaengig `System Hardening` zugeordnet. Die Implementierung kennt keine Sonderverzweigung fuer PingCastle oder `A-DC-Spooler`; der konkrete PingCastle-Fund, eine anders benannte Scannerregel, ein rein narrativer Fund und ein absichtlich mehrdeutiger Restfall sichern die Grenze. Die zentrale Work-Suche normalisiert den Suchbegriff selbst trim- und gross-/kleinschreibungsunabhaengig und durchsucht den sichtbaren Datensatztitel sowie alle erhaltenen Felder einschliesslich `Source Rule ID`. Aktive Status-, Facetten- und Scopefilter bleiben bewusst wirksam. 44 fokussierte Tests, Produktversionsvertrag, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestanden. Keine Kunden-, Import-, Persistenz-, Score- oder Reimportaenderung. Patchversion 2.5.9.
Source-Rule-ID in Result DB und Work 2026-09-23 (Phase 7/13C mit Datenschutzgrenze aus Phase 12/13B, Nutzerwunsch, umgesetzt und verifiziert): Native Scanner-Regelkennungen erhalten in der Work-Projektion die werkzeugunabhaengige Bezeichnung `Source Rule ID` und erscheinen als eigenes technisches Feld im Finding Reader; Work-Exporte und die oeffentliche regelspezifische Suche erkennen dieselbe Bezeichnung. Die Result DB zeigt die nach Disclosure Policy 2.3.0 freigegebene Source Rule ID als feste Tabellenspalte und weiterhin im Detail. Die Korrektur umfasst auch explizit gescopte Purple-Knight-AD-/Entra-/Kombinationslaeufe, deren gueltige `SI...`-ID bisher durch die Scope-Bezeichnung verloren ging. Originalfelder und Provenienz bleiben unveraendert; fehlende IDs werden nicht erzeugt, unbekannte oder nicht oeffentlich validierte IDs bleiben in der Result DB fail-closed. GPOs behalten ihre getrennt bezeichnete technische Setting-ID, da sie keine native Scanner-Rule-ID liefern. 133 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestanden. Gesamtsuite: 1.444 bestanden, 13 optionale Skips und die fuenf bekannten fachfremden Vertragsfehler. Live-Abnahme ausschliesslich im Mandanten `Dummy`: Result-DB-Spalte und Work-Reader-Feld in Light und Dark lesbar. Keine Schema-, Import-, Persistenz-, Reimport- oder Bestandsdatenmutation. Patchversion 2.5.8.
Result-DB-GPO-Nutzbarkeit und Purple-Knight-Entra-Scope 2026-09-23 (Phase 7/13C mit Datenschutzgrenze aus Phase 12/13B, Nutzerkorrektur, umgesetzt und verifiziert): Disclosure Policy 2.2.0 ersetzt fuer GPO-Vergleiche die unbrauchbaren generischen Titel und die vollstaendige Wertunterdrueckung. Result DB und ihr JSONL zeigen jetzt den konkreten Richtliniennamen beziehungsweise technischen Pfad, konfigurierte Kundenwerte, erwartete Baselinewerte, Baselinevarianten und eine nicht-hashartige technische Setting-ID; automatisch erzeugte `gpo-policy-identity-*`-Hashes werden nicht als vermeintlich hilfreiche Identitaet ausgegeben. Die bestehende Aussagegrenze bleibt sichtbar: Backupvergleich, kein Nachweis effektiver Endpunktkonfiguration. Scannerredaktion und Scope-Handle-Entfernung bleiben unveraendert. Purple-Knight-Findings tragen zusaetzlich einen kontrollierten Scope aus dem kanonischen ScanRun-Namen. `Purple Knight for Active Directory`, `Purple Knight for Entra ID` und ein explizit kombinierter Lauf erscheinen damit als getrennte Result-DB-Quellen und Filterwerte, waehrend Profil-ID und Scannerfamilie `purple-knight` bleiben. Historische unspezifische Purple-Knight-Laeufe werden nicht aus Titeln oder Regelnummern geraten und bleiben bis zum auditierten Reimport unter dem bisherigen Sammelwert. Verifikation ausschliesslich mit synthetischen Daten und im freigegebenen Mandanten `Dummy`: 50 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich; Gesamtlauf 1.443 bestanden, 13 optionale Skips und fuenf vorbestehende fachfremde Vertragsfehler. Keine Schema-, Import-, Persistenz- oder Bestandsdatenmutation. Patchversion 2.5.7.
Maester-Finding-Reader 2026-09-22 (Phase 7/13C, Nutzerkorrektur, umgesetzt und verifiziert): Work bildet Maester-Findings nun aus den erhaltenen Originalfeldern in der fachlichen Berichtsreihenfolge `ID`, `Test result`, `Test details`, `Category`, `Tags`, `Source` ab. Die eigentliche Ueberschrift verwendet ausschliesslich Maesters `Title` und wiederholt die native Regel-ID nicht aus dem ID-praefigierten `Name`; die ID bleibt darunter als eigenes technisches Feld sichtbar. Ausfuehrungsfehler bleiben als eingeklappte technische Detailinformation erhalten; Dauer, Index und doppelte Rohfelder fuellen weder Lesespalte noch Additional Information. Ein statusartiger Maester-`Result`-Wert wie `Failed` erzeugt keinen falschen Hinweis auf eine fehlende Results-Datei. Die oeffentliche AI-Suche erkennt `CISA.MS.EXO.17.3` als exakte Maester-Regel zur Microsoft-Purview-Auditaufbewahrung und nicht mehr als generische Windows-Audit-Policy. Der reale LSW-Bestand bestaetigte read-only die vollstaendige Quellbasis (Title, Result, Severity, Tag, Block, ErrorRecord, ScriptBlockFile und ScriptBlock); keine Bestandsmutation und kein Reimport. 83 fokussierte Reader-, Such-, Linker-, Import-, Titel-, Facetten- und Viewer-Tests sowie Client-/Server-Produktionsbuild bestanden.
PingCastle-Security-Domain-Vokabular 2026-09-22 (Phase 7/13C, Nutzerfehlerbericht, umgesetzt und verifiziert): `Unclassified` ist ausschliesslich eine fehlende Zuordnung zum gemeinsamen Security-Domain-Katalog und keine Aussage ueber Wichtigkeit, Severity, Ergebnis oder Scannererkennung. Der bisherige Katalog kannte mehrere eindeutige PingCastle-Begriffe nicht. `Obsolete OS`, `Hardened Paths`, Script Engines und CVE-Mitigation werden nun `System Hardening` zugeordnet; LDAPS, Channel/Anonymous Binding, rootDSE und veraltete LDAP-TLS-Versionen `Authentication & Access`; OU-Loeschschutz sowie NTFRS/SYSVOL `AD Infrastructure & PKI`. Ein synthetischer Work-Verhaltenstest bildet alle neun im Nutzerbeispiel sichtbaren Titel ab und verlangt null `Unclassified`. 28 fokussierte Domain-, Sidebar- und Filtertests sowie Client-/Server-Produktionsbuild bestanden. Reine gemeinsame Leseprojektion ohne Quellwert-, Severity-, Status-, Score-, Persistenz- oder Reimportaenderung.
Data-Hub-Reimporthinweis-am-Tool 2026-09-22 (Phase 7/13B, ausdruecklicher Nutzerwunsch, umgesetzt und verifiziert): Processing-, Review- und Reimporthinweise stehen nicht mehr als timeslotweiter Sammelblock nach allen Quellen. Jeder betroffene Scan zeigt unmittelbar unter seiner eigenen Tabellenzeile eine flache Hinweiszeile mit konkreter Erklaerung und ausschliesslich seiner Reimport- beziehungsweise sicheren Uebernahmeaktion. Kurzstatus, Scan-Auswahl, Delete-Aktion, Preservation Contract und atomarer Ersatzpfad bleiben unveraendert. Die Darstellung verwendet bestehende Data-Hub-Flaechen, Statusrollen, Typografie und Buttongeometrie; bei schmaler Breite fliesst die Aktion unter den Text. 61 fokussierte Tests sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung am Liftket-Timeslot bestaetigt die beiden jeweils direkt unter ihrem PurpleKnight-Scan stehenden Hinweise, einen unbeeinflussten bereiten PingCastle-Eintrag und die sichtbare Produktversion 2.5.6.
Maester-Unclassified-Suitefallback 2026-09-22 (Phase 7/13C, Nutzerfehlerbericht, umgesetzt): `Unclassified` bezeichnet in Work die fehlende Zuordnung zu einer der acht gemeinsamen Security Domains, nicht eine unbekannte Scannerherkunft. Maester-Felder `BLOCK`, `ID` und `TAG` waren bislang keine Domain-Evidenz; deshalb fielen insbesondere ORCA-/EXO-Findings trotz eindeutigem Exchange-Online-Suitecode in den Rest. Die gemeinsame Leseprojektion verwendet nun die stabilen Suitekennungen `ORCA`/`EXO`, `EIDSCA`, `CISA.MS.EXO`/`CISA.MS.AAD` und die entsprechenden Maester-Suiten ausschliesslich als Fallback fuer `Cloud & Applications`. Aussagekraeftige Titel-/Beschreibungsregeln behalten Vorrang, sodass etwa Conditional Access weiter `Authentication & Access` zugeordnet wird. Originalfelder, Scanneridentitaet, Ergebnisstatus, Score und Persistenz bleiben unveraendert; kein Reimport.
ASSESSOR-Produktversion 2.5.6 am 2026-09-22 (querschnittlich, Nutzerhinweis, umgesetzt): Die seit 2.5.5 hinzugekommenen rueckwaertskompatiblen Korrekturen werden als Patch-Release 2.5.6 ausgewiesen. `package.json` bleibt die einzige Produktversionsquelle; Header, About-Dialog und Health-API lesen denselben Wert. Paket-Lock und die UI-/HTTP-Vertragstests erwarten ebenfalls 2.5.6. Historische Pruefberichte behalten ihren dokumentierten damaligen Stand 2.5.5.
Purple-Knight-AD-ist-kein-Maester 2026-09-22 (Phase 13A/13B, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): `Purple Knight for Active Directory` ist ein eigenstaendiger Purple-Knight-Assessment-Scope und niemals Maester oder Entra ID. Sein kanonischer Tool-ID bleibt ausschliesslich `purple-knight`; AD-Zusammenfassungen erzeugen weder `maester.*`- noch `entra.*`-Kontext. Der historisch benannte Dashboard-Hilfsexport `dashboardMaesterTenantCount` wurde entfernt; die werkzeuguebergreifende Entra-Tenant-Zaehlung heisst ausschliesslich `dashboardEntraTenantCount`. Ein synthetischer Verhaltensregressionstest prueft den vollstaendigen AD-Import bis ScanRun, Tool-ID, Anzeigename und Umgebungsbeobachtungen.
Purple-Knight-AD-/Entra-Trennung 2026-09-22 (Phase 13A/13B, Nutzerwunsch, umgesetzt und verifiziert): Profil 1.4.0 und Summary-Extractor 1.2.0 erkennen sowohl die native AD- als auch die Entra-ID-Zusammenfassung. Scanlaeufe werden bei gemeinsamer Profil-ID eindeutig als `Purple Knight for Active Directory` beziehungsweise `Purple Knight for Entra ID` bezeichnet. Entra-Tenant, nativer Entra-Posture-Score, IOE-/Evaluated-Zaehler und Entra-Kategoriescores werden kanonisch mit eigener Zielidentitaet gespeichert; AD-Werte bleiben Forest-gebunden und werden nicht vermischt. Client Info zaehlt Entra-Tenants werkzeuguebergreifend und zeigt den Tenant im internen Directory-Kontext. Der reale Liftket-Bestand bestaetigte die Ursache read-only: AD-XLSX mit 119 Findings und 22 AD-Beobachtungen; separater Entra-CSV-Lauf mit 56 Findings, aber wegen generisch importierter Summary bisher 0 Beobachtungen. Der bestehende Lauf bleibt unveraendert und benoetigt den ausdruecklichen Reimport derselben acht Originaldateien, die ASSESSOR nicht aufbewahrt. 109 fokussierte Tests sowie Client-/Server-Produktionsbuild bestanden; keine Migration, Reimporte oder Bestandsmutation.
Result-DB-LLM-Datenschutzgrenze 2026-09-22 (Phase 12/13B, Nutzerkorrektur, umgesetzt und verifiziert): `result-db-disclosure` 2.1.0 behaelt bei bekannten Scannerprofilen die fuer ein LLM erforderlichen Finding-Titel, Beschreibungen, Empfehlungen und sicher validierten Regel-IDs. Personen-, Firmen-, Tenant-, Domain-, Host-, Konto-, Asset-, Pfad-, SID-, E-Mail- und interne Scope-/Provenienzwerte werden weiterhin entfernt oder in der Regelprosa durch typisierte Platzhalter ersetzt; HTML-Regelprosa wird zu lesbarem Klartext. Unbekannte Quellen, Zusatzbeobachtungen und kundenbezogene GPO-Werte bleiben fail-closed. Der explizite `internal-analysis`-Pfad behaelt Originale fuer Work und GPO Analysis; JSONL 2.0.0 wendet dieselbe Projektion erneut an und entfernt zusaetzlich Scope-/Result-/Run-/Artefakt-IDs, Zeitpunkte, Suchtext und konkrete Filterwerte. 45 fokussierte Domain-, Persistenz-, UI-, GPO- und Exporttests bestanden. Im laufenden PostgreSQL-Pfad besitzen alle 530 Purple-Knight-Zeilen wieder aussagekraeftige Titel/Beschreibungen und native SI-Regel-IDs, 501 eine Empfehlung; es wurden keine Asset-Wiederholungen, E-Mail-/UPN-, Domain-SID- oder Firmensuffixmuster ausgegeben. Keine Migration, Reimporte oder Bestandsdatenmutation; Phase 13B bleibt in Arbeit.
Maester-Unicode-Null-Import 2026-09-20 (Phase 13A/13C, Nutzerfehlerbericht, umgesetzt und verifiziert): Ein nativer Maester-Fehlertext enthielt `U+0000`. Vorschau und Mapping waren erfolgreich, der atomare PostgreSQL-Abschluss scheiterte jedoch, weil `jsonb` dieses Zeichen nicht repraesentieren kann; die bisherige Auto-Import-Fehlerbehandlung ersetzte danach die erfolgreiche Vorschau irrefuehrend durch `0 of 0 files selected`. Die gemeinsame kanonische Importgrenze stellt jedes solche Zeichen nun explizit als lesbares `\\u0000` dar, behaelt den gesamten umgebenden Quellwert, Hash und Locator und erzeugt die Diagnose `unicode-null-escaped`. Andere Steuerzeichen, Parsing, Profilwahl, Normalisierung und Bewertung bleiben unveraendert. Nach einem Abschlussfehler bleibt die vorbereitete Dateizeile samt Wiederholungsmoeglichkeit sichtbar. Ein synthetischer Maester-XLSX-Test und der vollstaendige HTTP-Pfad Vorschau -> bestaetigte Zuordnung -> Import in vorhandenen Timeslot sichern genau die Regression; 26 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestehen. Der optionale PostgreSQL-Testlauf wurde gemaess kanonischer-Datastore-Regel nicht gegen die bestehende Kundendatenbank ausgefuehrt. Keine Migration, Schema- oder Bestandsdatenaenderung; Phase 13A bleibt abgeschlossen, Phase 13C in Arbeit. Maester-Unicode-Null-Import 2026-09-20 (Phase 13A/13C, Nutzerfehlerbericht, umgesetzt und verifiziert): Ein nativer Maester-Fehlertext enthielt `U+0000`. Vorschau und Mapping waren erfolgreich, der atomare PostgreSQL-Abschluss scheiterte jedoch, weil `jsonb` dieses Zeichen nicht repraesentieren kann; die bisherige Auto-Import-Fehlerbehandlung ersetzte danach die erfolgreiche Vorschau irrefuehrend durch `0 of 0 files selected`. Die gemeinsame kanonische Importgrenze stellt jedes solche Zeichen nun explizit als lesbares `\\u0000` dar, behaelt den gesamten umgebenden Quellwert, Hash und Locator und erzeugt die Diagnose `unicode-null-escaped`. Andere Steuerzeichen, Parsing, Profilwahl, Normalisierung und Bewertung bleiben unveraendert. Nach einem Abschlussfehler bleibt die vorbereitete Dateizeile samt Wiederholungsmoeglichkeit sichtbar. Ein synthetischer Maester-XLSX-Test und der vollstaendige HTTP-Pfad Vorschau -> bestaetigte Zuordnung -> Import in vorhandenen Timeslot sichern genau die Regression; 26 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestehen. Der optionale PostgreSQL-Testlauf wurde gemaess kanonischer-Datastore-Regel nicht gegen die bestehende Kundendatenbank ausgefuehrt. Keine Migration, Schema- oder Bestandsdatenaenderung; Phase 13A bleibt abgeschlossen, Phase 13C in Arbeit.
Client-Info-Security-Domain-Mengenbalken 2026-09-20 (Phase 7, ausdrueckliche Nutzerentscheidung und nachfolgende Ausrichtungs-/Farbkorrekturen, umgesetzt und verifiziert): Jede angezeigte Security-Domain-Zeile traegt einen dezenten Mengenbalken in einer eigenen gemeinsamen Rasterspalte zwischen dem laengsten gerenderten Domainnamen und der breitesten gerenderten Mengenspalte. Dadurch liegen beidseitig 12 px Abstand; kein Balken kann in Schrift oder hinter eine Zahl wachsen. Alle Balken enden an derselben Kante und wachsen von dort nach links. Seine Breite entspricht dem sichtbaren Abweichungs- beziehungsweise Review-only-Wert der Zeile geteilt durch die Summe aller angezeigten Domainwerte; die exakte rechtsbuendige Zahl bleibt die autoritative sichtbare Angabe. Die spaetere Nutzerkorrektur ersetzt die kurzzeitig gewaehlte kategoriale `--viz-*`-Folge: Jeder Balken liest nun exakt denselben aktuellen semantischen Farbwert wie der Overall-Score-Donut und wechselt gemeinsam mit dessen Critical-, Medium-, Passed- oder Unknown-Tone. Die Balken sind rein dekorativ und fuer assistive Technik verborgen. Vier fokussierte Suites mit 23 Tests, die Designsprachpruefung sowie Client-/Server-Produktionsbuild bestehen. Die Live-Pruefung am freigegebenen Dummy bestaetigt fuer alle neun Zeilen getrennte Text-/Balken-/Zahlenspalten, beide 12-px-Abstaende, identische Donut-/Balkenfarbe `rgb(232, 33, 79)` und keinen horizontalen Ueberlauf in Light und Dark; Light wurde wiederhergestellt. Keine Daten-, Klassifikations-, Score-, Drilldown-, API- oder Persistenzaenderung. Client-Info-Security-Domain-Mengenbalken 2026-09-20 (Phase 7, ausdrueckliche Nutzerentscheidung und nachfolgende Ausrichtungs-/Farbkorrekturen, umgesetzt und verifiziert): Jede angezeigte Security-Domain-Zeile traegt einen dezenten Mengenbalken in einer eigenen gemeinsamen Rasterspalte zwischen dem laengsten gerenderten Domainnamen und der breitesten gerenderten Mengenspalte. Dadurch liegen beidseitig 12 px Abstand; kein Balken kann in Schrift oder hinter eine Zahl wachsen. Alle Balken enden an derselben Kante und wachsen von dort nach links. Seine Breite entspricht dem sichtbaren Abweichungs- beziehungsweise Review-only-Wert der Zeile geteilt durch die Summe aller angezeigten Domainwerte; die exakte rechtsbuendige Zahl bleibt die autoritative sichtbare Angabe. Die spaetere Nutzerkorrektur ersetzt die kurzzeitig gewaehlte kategoriale `--viz-*`-Folge: Jeder Balken liest nun exakt denselben aktuellen semantischen Farbwert wie der Overall-Score-Donut und wechselt gemeinsam mit dessen Critical-, Medium-, Passed- oder Unknown-Tone. Die Balken sind rein dekorativ und fuer assistive Technik verborgen. Vier fokussierte Suites mit 23 Tests, die Designsprachpruefung sowie Client-/Server-Produktionsbuild bestehen. Die Live-Pruefung am freigegebenen Dummy bestaetigt fuer alle neun Zeilen getrennte Text-/Balken-/Zahlenspalten, beide 12-px-Abstaende, identische Donut-/Balkenfarbe `rgb(232, 33, 79)` und keinen horizontalen Ueberlauf in Light und Dark; Light wurde wiederhergestellt. Keine Daten-, Klassifikations-, Score-, Drilldown-, API- oder Persistenzaenderung.
@ -1963,6 +1994,11 @@ Festlegung 2026-09-17 (Phase 13D, Promptbalance; ersetzt Prompt 1.3, nicht Retri
Festlegung 2026-09-18 (Phase 13D, Finding-only Retrieval; ersetzt die v1.4-Retrievalzusammensetzung, nicht deren historischen Eintrag): Regulatorische Kandidatensuche und Mapperkontext werden allein aus dem datenschutzsicheren Findingtitel, der Beschreibung und der Empfehlung erzeugt. Problem und Remediation bilden unabhaengige Queries; Scannername, Rule-ID, Catalog und kuratierte Topics sind keine Retrievalsignale. Dense-/Sparse-RRF und die anschliessende Query-RRF arbeiten auf Control-Ebene mit hoechstens einer Stimme je Control und Rangliste. Abschnittssuchen duerfen ausgewaehlte Controls mit `control`-, `purpose`- und `guidance`-Passagen belegen, aber die Zwolf-Control-Auswahl nicht veraendern. Queryschema 4 und Retrieval 1.5 versionieren die Aenderung append-only. Der produktive Runtimepfad verwendet keinen Reranker; ein solcher wird erst nach gesonderter Untersuchung aktiviert. Human Review, vollstaendige Kandidatenbewertung und deterministische 0-1/0-3-Auswahl bleiben verbindlich. Festlegung 2026-09-18 (Phase 13D, Finding-only Retrieval; ersetzt die v1.4-Retrievalzusammensetzung, nicht deren historischen Eintrag): Regulatorische Kandidatensuche und Mapperkontext werden allein aus dem datenschutzsicheren Findingtitel, der Beschreibung und der Empfehlung erzeugt. Problem und Remediation bilden unabhaengige Queries; Scannername, Rule-ID, Catalog und kuratierte Topics sind keine Retrievalsignale. Dense-/Sparse-RRF und die anschliessende Query-RRF arbeiten auf Control-Ebene mit hoechstens einer Stimme je Control und Rangliste. Abschnittssuchen duerfen ausgewaehlte Controls mit `control`-, `purpose`- und `guidance`-Passagen belegen, aber die Zwolf-Control-Auswahl nicht veraendern. Queryschema 4 und Retrieval 1.5 versionieren die Aenderung append-only. Der produktive Runtimepfad verwendet keinen Reranker; ein solcher wird erst nach gesonderter Untersuchung aktiviert. Human Review, vollstaendige Kandidatenbewertung und deterministische 0-1/0-3-Auswahl bleiben verbindlich.
Festlegung 2026-09-15 (Phase 13D, ausdruecklicher Nutzerauftrag): Qdrant ist der rebuildbare Suchindex fuer versionierte regulatorische Chunks und niemals fachliche Quelle fuer Scannergebnisse oder Mappingentscheidungen. PostgreSQL bleibt System of Record fuer Scans, Findings, Ergebnisse, Reviews und spaetere append-only Mappingprovenienz. Dense und Sparse sind benannte Vektorraeume; zunaechst wird nur mehrsprachiges Dense Retrieval aktiviert. Candidate Retrieval und optionaler Reranker bleiben getrennte Schnittstellen. Ein Modellwechsel darf alte Vektoren nicht still weiterverwenden; Modellname und Dimension werden explizit gebunden, Abfragen filtern auf das aktive Modell, und ein Korpus-Rebuild bleibt Pflicht. Der Embeddingdienst ist eine eigene externe Vertrauensgrenze; Findinginhalte duerfen erst nach einer getrennten Datenschutzfreigabe als minimierte Query uebertragen werden. Festlegung 2026-09-15 (Phase 13D, ausdruecklicher Nutzerauftrag): Qdrant ist der rebuildbare Suchindex fuer versionierte regulatorische Chunks und niemals fachliche Quelle fuer Scannergebnisse oder Mappingentscheidungen. PostgreSQL bleibt System of Record fuer Scans, Findings, Ergebnisse, Reviews und spaetere append-only Mappingprovenienz. Dense und Sparse sind benannte Vektorraeume; zunaechst wird nur mehrsprachiges Dense Retrieval aktiviert. Candidate Retrieval und optionaler Reranker bleiben getrennte Schnittstellen. Ein Modellwechsel darf alte Vektoren nicht still weiterverwenden; Modellname und Dimension werden explizit gebunden, Abfragen filtern auf das aktive Modell, und ein Korpus-Rebuild bleibt Pflicht. Der Embeddingdienst ist eine eigene externe Vertrauensgrenze; Findinginhalte duerfen erst nach einer getrennten Datenschutzfreigabe als minimierte Query uebertragen werden.
- 2026-09-23, Korrektur zur gemeinsamen Finding-Ueberschrift: Ein PingCastle-Regel-ID-Treffer allein beweist nicht dieselbe Berichtsversion. Die Leseprojektion darf einen besseren Titel nur aus dem eindeutig gepaarten Bericht desselben Projekts, Datums und Dateistamms uebernehmen; bei gleichrangigen widerspruechlichen Titeln bleibt der eigene native Titel. Die fruehere Entscheidung fuer den kanonischen Definitionstitel gilt innerhalb eines Vorkommnisses weiter, war fuer getrennte HTML-/XML-Vorkommnisse aber unvollstaendig. Oeffentliche PingCastle-IDs werden nur gegen den dokumentierten Regelkatalog validiert und in nativer Schreibweise angezeigt.
- 2026-09-23, gemeinsame Finding-Ueberschrift: Result DB und Work fuehren Scannerfindings mit dem kanonischen `FindingDefinition.title`. Vorkommnisspezifische native Titel duerfen die Work-Ueberschrift nicht mehr ueberschreiben; bei Abweichung bleiben sie als `Native source heading` in der Leseprojektion sichtbar. Diese Regel ist reine Praesentation und aendert weder Originalfeld noch Provenienz, Finding-Identitaet oder gespeicherte Daten.
**Security-Domain-Mengenbalken (2026-09-20):** Die Verteilung innerhalb der sichtbaren Client-Info-Security-Domains wird als dezenter Balken in einer eigenen Subgrid-Spalte dargestellt. Das gemeinsame Raster bemisst links den laengsten Domainnamen und rechts die breiteste Mengenzahl; der Balken bleibt mit gemeinsamem Abstand vollstaendig dazwischen, endet bei allen Zeilen an derselben Kante und waechst nach links. Alle Balken erben exakt den gemeinsam am Posture-Bereich gesetzten Tone des Overall Scores; dadurch stimmen sie immer mit der aktuellen Donutfarbe ueberein, waehrend die einzelnen Domainknoten weiterhin ihre eigene Pass-Rate-Farbe besitzen. Bezugsmenge ist die Summe exakt der angezeigten Domainmengen; eine Zeile verwendet denselben Wert, den ihre rechte Zahl bereits zeigt, einschliesslich des bestehenden Review-only-Falls. Der Balken ergaenzt die Zahl nur visuell, ist `aria-hidden` und darf weder fachliche Mengen noch Bewertung, Klassifikation oder Navigation beeinflussen. **Security-Domain-Mengenbalken (2026-09-20):** Die Verteilung innerhalb der sichtbaren Client-Info-Security-Domains wird als dezenter Balken in einer eigenen Subgrid-Spalte dargestellt. Das gemeinsame Raster bemisst links den laengsten Domainnamen und rechts die breiteste Mengenzahl; der Balken bleibt mit gemeinsamem Abstand vollstaendig dazwischen, endet bei allen Zeilen an derselben Kante und waechst nach links. Alle Balken erben exakt den gemeinsam am Posture-Bereich gesetzten Tone des Overall Scores; dadurch stimmen sie immer mit der aktuellen Donutfarbe ueberein, waehrend die einzelnen Domainknoten weiterhin ihre eigene Pass-Rate-Farbe besitzen. Bezugsmenge ist die Summe exakt der angezeigten Domainmengen; eine Zeile verwendet denselben Wert, den ihre rechte Zahl bereits zeigt, einschliesslich des bestehenden Review-only-Falls. Der Balken ergaenzt die Zahl nur visuell, ist `aria-hidden` und darf weder fachliche Mengen noch Bewertung, Klassifikation oder Navigation beeinflussen.
- 2026-09-20, Mindestgroessen fuer Information und Lesetext: Primaere Information in Tabellen, Kacheln und Feldbeschriftungen darf nicht unter 14 px fallen; zu lesende Werte und fortlaufender Text verwenden 16 px. Kleinere Rollen sind ausschliesslich fuer kompakte Controls, technische Metadaten und Badges zulaessig und duerfen nicht zum Verdichten primaerer Information missbraucht werden. Bestehende semantische 14-/16-px-Tokens bleiben die Quelle; keine lokale Literal- oder View-Sondergroesse. - 2026-09-20, Mindestgroessen fuer Information und Lesetext: Primaere Information in Tabellen, Kacheln und Feldbeschriftungen darf nicht unter 14 px fallen; zu lesende Werte und fortlaufender Text verwenden 16 px. Kleinere Rollen sind ausschliesslich fuer kompakte Controls, technische Metadaten und Badges zulaessig und duerfen nicht zum Verdichten primaerer Information missbraucht werden. Bestehende semantische 14-/16-px-Tokens bleiben die Quelle; keine lokale Literal- oder View-Sondergroesse.
@ -2008,6 +2044,12 @@ Festlegung 2026-09-09 (Phase 13/13A, HTML-Timeout): Attributtoken werden monoton
Festlegung 2026-09-09 (Phase 13A/13C, PurpleKnight): Kontext-Extractors duerfen keine anderen Findingtabellen desselben Dokuments verdraengen; sie komponieren deren generische Extraktion unter Erhaltung der Quellindizes. Native Berichtssyntax gehoert in den registrierten Source Extractor, der gemeinsame statische Leser bleibt werkzeug- und plattformneutral. HTML-Metriken verwenden dieselben semantischen Kontextvertraege wie die Zusammenfassungstabellen; unbekannte Originaldaten bleiben separat erhalten. Abgeleitete Anzeigefelder zeigen auf reale Eingabeobjekte statt erfundene Quellfelder. Zeitpraezision wird nicht fuer Formatparitaet reduziert. Fehlende IDs und unlesbare/konkurrierende Objekte scheitern, doppelte Regel-IDs bleiben getrennte Vorkommen mit Diagnose. Profil 1.3.0 versioniert Erfassung und Evidenz; Mapping/Normalisierung/Praesentation 1.1.0 und kanonischer Kompatibilitaetsschluessel bleiben erhalten. Reimport ist empfohlen, aber ohne ausdruecklichen Auftrag nicht durchgefuehrt. Festlegung 2026-09-09 (Phase 13A/13C, PurpleKnight): Kontext-Extractors duerfen keine anderen Findingtabellen desselben Dokuments verdraengen; sie komponieren deren generische Extraktion unter Erhaltung der Quellindizes. Native Berichtssyntax gehoert in den registrierten Source Extractor, der gemeinsame statische Leser bleibt werkzeug- und plattformneutral. HTML-Metriken verwenden dieselben semantischen Kontextvertraege wie die Zusammenfassungstabellen; unbekannte Originaldaten bleiben separat erhalten. Abgeleitete Anzeigefelder zeigen auf reale Eingabeobjekte statt erfundene Quellfelder. Zeitpraezision wird nicht fuer Formatparitaet reduziert. Fehlende IDs und unlesbare/konkurrierende Objekte scheitern, doppelte Regel-IDs bleiben getrennte Vorkommen mit Diagnose. Profil 1.3.0 versioniert Erfassung und Evidenz; Mapping/Normalisierung/Praesentation 1.1.0 und kanonischer Kompatibilitaetsschluessel bleiben erhalten. Reimport ist empfohlen, aber ohne ausdruecklichen Auftrag nicht durchgefuehrt.
Festlegung 2026-09-22 (Phase 13A/13B, Purple Knight AD und Entra ID): Active Directory und Entra ID sind getrennte Assessment-Scopes desselben Purple-Knight-Profils, nicht austauschbare Scanner. Der kanonische Tool-ID bleibt `purple-knight`, waehrend ScanRun und Scorequelle den Scope lesbar benennen. AD bindet Forest und `purple_knight.ad.*`; Entra bindet `entra.tenant_name` und `purple_knight.entra.*`. Native Scores duerfen nur innerhalb ihres Scopes verwendet werden. Ein Batch mit beiden Scopes wird ausdruecklich als kombiniert bezeichnet und nie still als AD oder Entra ausgegeben. Bestehende 1.3.0-Importe bleiben historisch unveraendert und benoetigen fuer den neuen Entra-Kontext einen auditierten Reimport.
Festlegung 2026-09-22 (Phase 7/13C, Maester Security Domain): Eine vorhandene Maester-Scanneridentitaet und `Unclassified` sind verschiedene Achsen. `Unclassified` darf nur die fehlende fachliche Security-Domain-Zuordnung ausdruecken. Offizielle stabile Maester-Suite-/Control-Praefixe duerfen als enger Fallback dienen, wenn Titel und beschreibende Felder keine spezifischere gemeinsame Domain liefern; sie duerfen Status, Severity, Scanneridentitaet oder Quellkategorien nicht veraendern. ORCA/EXO und Entra-Suiten fallen dabei unter `Cloud & Applications`; spezifische Inhaltsregeln wie Conditional Access behalten Vorrang.
Nutzerkorrektur 2026-09-22 (Phase 13A/13B, Purple Knight AD gegen Maester): `Purple Knight for Active Directory` ist kein Maester-Scan. Gemeinsame Directory-Anzeigeflaechen oder eine werkzeuguebergreifende Tenant-Zaehlung begruenden keinerlei Scanneridentitaet. Purple-Knight-AD behaelt Tool-ID `purple-knight`, AD-Scope und ausschliesslich AD-/Forest-Kontext; Maester behaelt Tool-ID `maester` und seinen eigenen Entra-Testkontext. Kein Import-, Scoring-, Filter- oder Darstellungspfad darf Purple-Knight-AD als Maester bezeichnen, klassifizieren oder auswerten.
Festlegung 2026-09-09 (Phase 13A/13C, PingCastle): Originale bleiben unveraendert. XML-Attribute werden als exakte Name/Wert-Paare mit eigenen Quellpfaden neben Text und expliziter Leere gefuehrt; Attribut-Anzeigezeichenketten sind kein verlustfreies Austauschformat. Der generische HTML-Leser schuetzt zitierte Attributgrenzen, bereinigt nur ganze Attribute und behandelt abgeschnittene Textausschnitte getrennt von vollstaendigen Dokumenten. Native Rule IDs sind erforderlich. Die historische First-Presentation-Deduplizierung bleibt erhalten, wird aber mit einer Warnung sichtbar. Kategorie-Trefferzahlen werden gegen eindeutige Regeln, nicht gegen Kataloggroessen geprueft; Originalzaehler erhalten einen separaten Reportabschnitt ausserhalb der Assetbeobachtungen. Profil 1.5.0 und Extractor 2.2.0 versionieren die Aenderung; Mapping 1.2.0, Normalisierung 1.1.0 und Kompatibilitaetsschluessel bleiben erhalten. Festlegung 2026-09-09 (Phase 13A/13C, PingCastle): Originale bleiben unveraendert. XML-Attribute werden als exakte Name/Wert-Paare mit eigenen Quellpfaden neben Text und expliziter Leere gefuehrt; Attribut-Anzeigezeichenketten sind kein verlustfreies Austauschformat. Der generische HTML-Leser schuetzt zitierte Attributgrenzen, bereinigt nur ganze Attribute und behandelt abgeschnittene Textausschnitte getrennt von vollstaendigen Dokumenten. Native Rule IDs sind erforderlich. Die historische First-Presentation-Deduplizierung bleibt erhalten, wird aber mit einer Warnung sichtbar. Kategorie-Trefferzahlen werden gegen eindeutige Regeln, nicht gegen Kataloggroessen geprueft; Originalzaehler erhalten einen separaten Reportabschnitt ausserhalb der Assetbeobachtungen. Profil 1.5.0 und Extractor 2.2.0 versionieren die Aenderung; Mapping 1.2.0, Normalisierung 1.1.0 und Kompatibilitaetsschluessel bleiben erhalten.
Festlegung 2026-09-09 (Phase 13A/13C, Maester): Eingebettete Berichtsdaten sind Daten, kein auszufuehrendes Programm. Der native Extractor erweitert die generische Pipeline und liefert codekontrollierte Werkzeugerkennung, vollstaendige Originalfelder mit Quellpfaden sowie getrennten Berichtskontext. Statische Objektliterale sind nur innerhalb der dokumentierten Grammatik und Ressourcengrenzen zulaessig; dynamische oder mehrdeutige Berichtswurzeln werden explizit abgelehnt. Semantisch mehrdeutige Feldnamen werden anhand der Spalten desselben Kandidaten beziehungsweise derselben Tabelle abgebildet, nicht anhand des Dateityps allein. Gruppierungs- und Wiederverwendungsschluessel beruecksichtigen diesen Kontext. Result-Nachricht und Statusfilter verwenden dieselbe Prioritaet; Evidenztext darf keinen zweiten Ergebnisstatus erzeugen. Native Summenzaehler werden nicht als Vollstaendigkeitsbeweis benutzt oder ueberschrieben, sondern gegen die Einzeltests abgeglichen. Maester-Profil, Mapping und Praesentation steigen auf 1.1.0 bei unveraenderter Normalisierung und Kompatibilitaetsschluessel; historische Verarbeitungsstaende bleiben lesbar und werden als Reimport empfohlen ausgewiesen. Festlegung 2026-09-09 (Phase 13A/13C, Maester): Eingebettete Berichtsdaten sind Daten, kein auszufuehrendes Programm. Der native Extractor erweitert die generische Pipeline und liefert codekontrollierte Werkzeugerkennung, vollstaendige Originalfelder mit Quellpfaden sowie getrennten Berichtskontext. Statische Objektliterale sind nur innerhalb der dokumentierten Grammatik und Ressourcengrenzen zulaessig; dynamische oder mehrdeutige Berichtswurzeln werden explizit abgelehnt. Semantisch mehrdeutige Feldnamen werden anhand der Spalten desselben Kandidaten beziehungsweise derselben Tabelle abgebildet, nicht anhand des Dateityps allein. Gruppierungs- und Wiederverwendungsschluessel beruecksichtigen diesen Kontext. Result-Nachricht und Statusfilter verwenden dieselbe Prioritaet; Evidenztext darf keinen zweiten Ergebnisstatus erzeugen. Native Summenzaehler werden nicht als Vollstaendigkeitsbeweis benutzt oder ueberschrieben, sondern gegen die Einzeltests abgeglichen. Maester-Profil, Mapping und Praesentation steigen auf 1.1.0 bei unveraenderter Normalisierung und Kompatibilitaetsschluessel; historische Verarbeitungsstaende bleiben lesbar und werden als Reimport empfohlen ausgewiesen.
@ -2028,6 +2070,10 @@ Festlegung 2026-09-06 (Phase 13B, Baseline-Reimport): Eine ausdruecklich bestaet
Festlegung 2026-09-06 (Phase 12/13B, Datenschutzgrenze der Result DB): Die Result DB enthaelt kanonische Findings und ausschliesslich explizit freigegebene, nicht identifizierende Zusatzbeobachtungen. Eine versionierte tool-/propertybezogene Allowlist rekonstruiert nur kontrollierte Titel, Klassen und eng validierte Aggregate, Konfigurationszustaende oder technische Metadaten; Subject-Identitaet, Originalwert, Attribute, Asset, Abschnitt und Locator bleiben intern. Unbekannte beziehungsweise zukuenftige Tools behalten ihre regulaeren Findings im generischen Importpfad, ihre Zusatzfelder bleiben jedoch bis zur Datenschutzklassifikation fail-closed ausserhalb der Result DB. Rohe GPO-Policy-Beobachtungen sind nur noch im ausdruecklichen internen Analysemodus fuer Work/GPO Analysis lesbar; bestaetigte Policy-Findings bleiben Result-DB-faehig. Diese Festlegung supersediert die Entscheidung vom 2026-09-03, rohe GPO-Policy-Beobachtungen auch in der Result DB darzustellen. Festlegung 2026-09-06 (Phase 12/13B, Datenschutzgrenze der Result DB): Die Result DB enthaelt kanonische Findings und ausschliesslich explizit freigegebene, nicht identifizierende Zusatzbeobachtungen. Eine versionierte tool-/propertybezogene Allowlist rekonstruiert nur kontrollierte Titel, Klassen und eng validierte Aggregate, Konfigurationszustaende oder technische Metadaten; Subject-Identitaet, Originalwert, Attribute, Asset, Abschnitt und Locator bleiben intern. Unbekannte beziehungsweise zukuenftige Tools behalten ihre regulaeren Findings im generischen Importpfad, ihre Zusatzfelder bleiben jedoch bis zur Datenschutzklassifikation fail-closed ausserhalb der Result DB. Rohe GPO-Policy-Beobachtungen sind nur noch im ausdruecklichen internen Analysemodus fuer Work/GPO Analysis lesbar; bestaetigte Policy-Findings bleiben Result-DB-faehig. Diese Festlegung supersediert die Entscheidung vom 2026-09-03, rohe GPO-Policy-Beobachtungen auch in der Result DB darzustellen.
Festlegung 2026-09-22 (Phase 12/13B, LLM-faehige Result DB): Das ausdrueckliche Nutzerziel supersediert den Teil der vorstehenden Entscheidung, nach dem regulaere Findings ihre importierten Regeltexte und GPO-Vergleiche ihre Kundenwerte in der normalen Result DB behalten duerfen. Die Result DB ist nun fuer jede Quellart eine Disclosure-Projektion 2.0.0. Nur kontrolliertes Produktvokabular, katalogeigene oeffentliche Regel-IDs, normalisierte Bewertungsachsen, kontrollierte Diagnosecodes und eng validierte nicht identifizierende Zusatzwerte duerfen passieren. Freie Quellprosa, Personen-/Firmen-/Tenant-/Domain-/Host-/Assetdaten, Reviewpersonen, Policy-Ist-/Sollwerte und interne Provenienz bleiben im `internal-analysis`-Pfad. Der JSONL-Export ist eine zweite unabhaengige Sperre und entfernt auch Scope-/Result-/Run-/Artefaktidentitaeten, Zeitpunkte und konkrete Such-/Filterwerte. Die Aussage betrifft Identifizierbarkeit; die anonymisierte technische Sicherheitsbewertung bleibt absichtlich erhalten, da andernfalls keine fachlich nutzbare LLM-Grundlage existiert.
Nutzerkorrektur 2026-09-22 (Phase 12/13B, fachlich nutzbare Result DB): Die vollstaendige Ersetzung der Regelprosa durch generische Tooltitel war eine unbrauchbare Ueberkorrektur und wird durch Disclosure Policy 2.1.0 supersediert. Bekannte Scannerprofile behalten Titel, Beschreibung und Empfehlung als fachlichen Regelinhalt; Purple Knight behaelt zusaetzlich eng validierte native `SI`-Regel-IDs. Die Projektion entfernt exakte private Literale aus Asset-, Review-, Scope- und Locatorfeldern und redigiert E-Mail/UPN, Domain, SID, Directory-Objekt, Konto, IP/MAC, URL und Pfad sowie Organisationsnamen mit Rechtsformsuffix durch typisierte Platzhalter. Unbekannte Quellen bleiben generisch; Asset und Reviewperson selbst werden nicht ausgegeben. Eine UI-Regression muss sichern, dass das Aufklappen einer Result-DB-Zeile zugleich fachlichen Inhalt zeigt und das betroffene Objekt verbirgt.
Festlegung 2026-09-06 (Phase 13B/13C, ausdruecklicher Nutzerwunsch): Vor Rueckfragen zum GPO-Zielkontext vorhandene Result-DB-/PingCastle-Umgebungsbeobachtungen im selben Assessment auswerten. Belegte DC-Betriebssystemkontexte duerfen mit Beobachtungsprovenienz automatisch entstehen; dies erweitert die bisherige ausschliesslich manuelle Profilwahl. Domain-/Forest-Functional-Level, Herkunft des Backups vom DC und Registry-Pfade allein beweisen weder Betriebssystemversion noch GPO-Anwendung auf DC/Member/Client. Gemischte oder unzureichend belegte Plattformen bleiben klaerungsbeduerftig. Technische Identitaeten gleichen konkrete Einstellungen ab, nicht GPO-Namen. Festlegung 2026-09-06 (Phase 13B/13C, ausdruecklicher Nutzerwunsch): Vor Rueckfragen zum GPO-Zielkontext vorhandene Result-DB-/PingCastle-Umgebungsbeobachtungen im selben Assessment auswerten. Belegte DC-Betriebssystemkontexte duerfen mit Beobachtungsprovenienz automatisch entstehen; dies erweitert die bisherige ausschliesslich manuelle Profilwahl. Domain-/Forest-Functional-Level, Herkunft des Backups vom DC und Registry-Pfade allein beweisen weder Betriebssystemversion noch GPO-Anwendung auf DC/Member/Client. Gemischte oder unzureichend belegte Plattformen bleiben klaerungsbeduerftig. Technische Identitaeten gleichen konkrete Einstellungen ab, nicht GPO-Namen.
Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert. Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert.
@ -2359,6 +2405,11 @@ Retrieval-Gap-Benchmark 2026-09-17, korrigiert 2026-09-18: Die zehn PingCastle-F
Retrieval-Gesamtbenchmark 2026-09-17: Der nachtraegliche Abgleich aller 37 Findings belegte die Auswahlverzerrung des reinen Gap-Satzes: v1.2 traf dort `17/17`, verschlechterte ueber alle 78 erwarteten Beziehungen jedoch v1.1 von `61/78` auf `54/78`. Die generisch reservierte v1.3-Zusammensetzung erreicht `68/78` (`87,2 %`), wurde aber auf demselben PingCastle-Satz ausgewaehlt und kann deshalb ueberangepasst sein. Gegenmassnahmen sind die harte Abhaengigkeitsfreiheit vom Topic-Katalog, ein Unknown-Scanner-Vertrag, unveraendertes Human Review sowie als naechster Qualitaetsschritt ein separat gelabelter Multi-Scanner-Satz mit direkten, unterstuetzenden und bewusst nicht relevanten Controls. Candidate Recall darf weder als Mapperpraezision noch als Annahmequote oder Compliancebewertung ausgegeben werden. Retrieval-Gesamtbenchmark 2026-09-17: Der nachtraegliche Abgleich aller 37 Findings belegte die Auswahlverzerrung des reinen Gap-Satzes: v1.2 traf dort `17/17`, verschlechterte ueber alle 78 erwarteten Beziehungen jedoch v1.1 von `61/78` auf `54/78`. Die generisch reservierte v1.3-Zusammensetzung erreicht `68/78` (`87,2 %`), wurde aber auf demselben PingCastle-Satz ausgewaehlt und kann deshalb ueberangepasst sein. Gegenmassnahmen sind die harte Abhaengigkeitsfreiheit vom Topic-Katalog, ein Unknown-Scanner-Vertrag, unveraendertes Human Review sowie als naechster Qualitaetsschritt ein separat gelabelter Multi-Scanner-Satz mit direkten, unterstuetzenden und bewusst nicht relevanten Controls. Candidate Recall darf weder als Mapperpraezision noch als Annahmequote oder Compliancebewertung ausgegeben werden.
Work-LiteLLM 2026-09-14: Der Unternehmensgateway ist eine ausdruecklich freigegebene externe Vertrauensgrenze und kann Requests gemaess eigener Betriebsrichtlinie routen oder protokollieren; ASSESSOR kann diese Aufbewahrung nicht kontrollieren. Die API sendet deshalb nur den vorhandenen Privacy-Safe-Export nach explizitem Nutzerklick, nie Rohdateien, Reviews, Quelltabellen oder Storage Keys. Gatewayausfall, Modellwechsel oder ungueltige JSON-Ausgabe lassen den bisherigen erfolgreichen Run unveraendert und erfordern eine bewusste neue Generierung. Der synthetische Workstation-Connectivitycheck erreichte die konfigurierte URL, erhielt aber HTTP 401; Keyaktivierung, Berechtigung oder Gatewaykonfiguration muessen betrieblich geklaert werden, ohne Secrets oder Providerbody offenzulegen. Migration 024 ist in diesem Lauf mangels Docker weder auf den kanonischen lokalen Datastore angewandt noch per PostgreSQL-Integration verifiziert; vor Anwendung bleiben verifiziertes Backup und erfolgreicher synthetischer Workstation-/Server-Liveaufruf verpflichtend. Work-LiteLLM 2026-09-14: Der Unternehmensgateway ist eine ausdruecklich freigegebene externe Vertrauensgrenze und kann Requests gemaess eigener Betriebsrichtlinie routen oder protokollieren; ASSESSOR kann diese Aufbewahrung nicht kontrollieren. Die API sendet deshalb nur den vorhandenen Privacy-Safe-Export nach explizitem Nutzerklick, nie Rohdateien, Reviews, Quelltabellen oder Storage Keys. Gatewayausfall, Modellwechsel oder ungueltige JSON-Ausgabe lassen den bisherigen erfolgreichen Run unveraendert und erfordern eine bewusste neue Generierung. Der synthetische Workstation-Connectivitycheck erreichte die konfigurierte URL, erhielt aber HTTP 401; Keyaktivierung, Berechtigung oder Gatewaykonfiguration muessen betrieblich geklaert werden, ohne Secrets oder Providerbody offenzulegen. Migration 024 ist in diesem Lauf mangels Docker weder auf den kanonischen lokalen Datastore angewandt noch per PostgreSQL-Integration verifiziert; vor Anwendung bleiben verifiziertes Backup und erfolgreicher synthetischer Workstation-/Server-Liveaufruf verpflichtend.
- 2026-09-23, PingCastle-Berichtspaarung: Derselbe Dateistamm und dasselbe Berichtsdatum koennen theoretisch mehrere Importe umfassen. Widerspruechliche gleichrangige Titel werden deshalb nicht vereinheitlicht; ohne passende oeffentliche Regel-ID bleibt die Result-DB-ID aus Datenschutzgruenden leer. Die synthetischen Tests decken die HTML-Auswahl mit gepaartem XML sowie unpassende und widerspruechliche Berichte ab. Eine Abnahme im gemeldeten Kundenscope ist ausdruecklich nicht erfolgt.
- 2026-09-23, gemeinsame Finding-Ueberschrift: Ein kanonischer Definitionstitel kann von einer konkreten Scannerdatei abweichen oder dort zusaetzliche vorkommnisspezifische Zahlen enthalten. Work verwendet ihn bewusst als gemeinsame fachliche Ueberschrift, darf den nativen Quelltitel aber nicht verlieren oder als kanonische Identitaet behandeln. Die synthetische Regression prueft deshalb beide normalen Work-Projektionen, die sichtbare native Ueberschrift und das unveraenderte Originalfeld. Datenschutzbedingte Result-DB-Redaktion bleibt eine separate Ausgabeschicht; Work uebernimmt den kanonischen Titel, nicht die externe Disclosure-Projektion.
| Ein flaechenfuellender Mengenbalken kann in Domainnamen hineinwachsen, hinter unterschiedlich breiten Zahlen enden oder durch eine vom Overall Score abweichende Farbe eine zweite Bedeutung suggerieren. Eine Maximalwert-Normalisierung wuerde zudem keine Anteile an der Gesamtverteilung zeigen. | Ein gemeinsames Vier-Spalten-Subgrid fuer Score, laengsten Namen, Balken und breiteste Zahl verwenden. Balken bleiben mit gemeinsamem Abstand ausschliesslich in ihrer dritten Spalte, erben den am Posture-Bereich gesetzten Overall-Tone und werden gegen die Summe der angezeigten Domainmengen skaliert. Exakte Zahlen bleiben rechtsbuendig sichtbar und autoritativ; `aria-hidden`, synthetische 75/25-Regression sowie Light-/Dark-, Donutfarb-, Kanten- und Ueberlaufpruefung sichern die Grenze. | | Ein flaechenfuellender Mengenbalken kann in Domainnamen hineinwachsen, hinter unterschiedlich breiten Zahlen enden oder durch eine vom Overall Score abweichende Farbe eine zweite Bedeutung suggerieren. Eine Maximalwert-Normalisierung wuerde zudem keine Anteile an der Gesamtverteilung zeigen. | Ein gemeinsames Vier-Spalten-Subgrid fuer Score, laengsten Namen, Balken und breiteste Zahl verwenden. Balken bleiben mit gemeinsamem Abstand ausschliesslich in ihrer dritten Spalte, erben den am Posture-Bereich gesetzten Overall-Tone und werden gegen die Summe der angezeigten Domainmengen skaliert. Exakte Zahlen bleiben rechtsbuendig sichtbar und autoritativ; `aria-hidden`, synthetische 75/25-Regression sowie Light-/Dark-, Donutfarb-, Kanten- und Ueberlaufpruefung sichern die Grenze. |
- 2026-09-20, Lesetypografie: Groessere 14-/16-px-Rollen koennen bei langen Werten mehr Zeilen und vertikalen Scrollbedarf erzeugen. Inhalte duerfen deshalb umbrechen oder in den bereits dafuer vorgesehenen Kacheln scrollen, aber niemals gekuerzt, ellipsiert oder wieder auf kleinere Schrift reduziert werden. Kompakte Metadatenrollen bleiben nur fuer tatsaechlich sekundaere technische Angaben zulaessig; ihre Verwendung fuer Primaerinformation waere eine Regression. - 2026-09-20, Lesetypografie: Groessere 14-/16-px-Rollen koennen bei langen Werten mehr Zeilen und vertikalen Scrollbedarf erzeugen. Inhalte duerfen deshalb umbrechen oder in den bereits dafuer vorgesehenen Kacheln scrollen, aber niemals gekuerzt, ellipsiert oder wieder auf kleinere Schrift reduziert werden. Kompakte Metadatenrollen bleiben nur fuer tatsaechlich sekundaere technische Angaben zulaessig; ihre Verwendung fuer Primaerinformation waere eine Regression.
@ -2405,6 +2456,8 @@ HTML-Timeout-Restgrenze 2026-09-09: Die konkrete Datei aus dem Screenshot wurde
PurpleKnight-Importgrenzen 2026-09-09 (Phase 13A/13C): Verifiziert sind die freigegebenen CSV-/XLSX-/HTML-Exporte und synthetische Varianten, nicht jede kuenftige Toolversion. Ohne deklarierte Summen ist kein vollstaendiger rechnerischer Bilanznachweis moeglich. Die statische Datengrammatik ist kein allgemeiner JavaScript-Interpreter; neue dynamische Exportvarianten benoetigen eine begrenzte Erweiterung, keine Ausfuehrung. Mehrfachvorkommen und Widersprueche bleiben sichtbar statt heuristisch zusammengefuehrt zu werden. Die gelieferten CSVs und HTML/XLSX stammen aus unterschiedlichen Scanzeitpunkten und duerfen nicht auf identische Statussummen gezwungen werden. Bestehende Importe bleiben unveraendert und benoetigen Reimport fuer neue Erfassungsdaten. AD-Domaenen-Begleitdateien bleiben als Rohquellen erhalten; die neue typisierte HTML-Domaenenliste ersetzt keine allgemeine Inventarmodellierung. Keine Persistenzmigration, optionale PostgreSQL-Integration nicht aktiviert; bestehender Client-Bundlegroessenhinweis bleibt offen. PurpleKnight-Importgrenzen 2026-09-09 (Phase 13A/13C): Verifiziert sind die freigegebenen CSV-/XLSX-/HTML-Exporte und synthetische Varianten, nicht jede kuenftige Toolversion. Ohne deklarierte Summen ist kein vollstaendiger rechnerischer Bilanznachweis moeglich. Die statische Datengrammatik ist kein allgemeiner JavaScript-Interpreter; neue dynamische Exportvarianten benoetigen eine begrenzte Erweiterung, keine Ausfuehrung. Mehrfachvorkommen und Widersprueche bleiben sichtbar statt heuristisch zusammengefuehrt zu werden. Die gelieferten CSVs und HTML/XLSX stammen aus unterschiedlichen Scanzeitpunkten und duerfen nicht auf identische Statussummen gezwungen werden. Bestehende Importe bleiben unveraendert und benoetigen Reimport fuer neue Erfassungsdaten. AD-Domaenen-Begleitdateien bleiben als Rohquellen erhalten; die neue typisierte HTML-Domaenenliste ersetzt keine allgemeine Inventarmodellierung. Keine Persistenzmigration, optionale PostgreSQL-Integration nicht aktiviert; bestehender Client-Bundlegroessenhinweis bleibt offen.
Purple-Knight-Entra-Risiko 2026-09-22: Ein Tenantname, der nur in einer alten generisch importierten Summary existiert, darf nicht aus Dateinamen, Findingtiteln oder AAD-Regelkennungen geraten werden. Profil 1.4.0 liest ihn ausschliesslich aus der nativen `Entra ID: tenant`-Zeile und speichert deren Quellposition. Fehlen Originaldateien fuer einen historischen 1.3.0-Lauf, bleibt Client Info unbekannt; ein stiller Bestandsumbau aus praesentativen Tabellen ist unzulaessig. AD- und Entra-Scores bleiben getrennt, auch wenn beide denselben numerischen Wert besitzen.
PingCastle-Importgrenzen 2026-09-09 (Phase 13A/13C): Verifiziert sind das freigegebene Dummy-Paar und synthetische Varianten, nicht jede PingCastle-Version. Ohne native Zaehler kann kein rechnerischer Vollstaendigkeitsnachweis erfolgen. Doppelte IDs werden diagnostiziert, bleiben aber gemaess bisheriger Entscheidung ein Finding mit erster Darstellung; es findet keine automatische Zusammenfuehrung widerspruechlicher Darstellungen statt. Der bekannte fehlende HTML-Anker und die XML-Results-Einschraenkung bleiben sichtbare Warnungen. Generische XML-Attribute sind keine automatische Assetklassifikation; semantisches XML-Mapping und Control-Path-JSON bleiben offen. Alte Importe benoetigen Reimport. Keine Schema-/Datenmigration, optionale PostgreSQL-Integration nicht aktiviert; vorhandene Client-Bundlegroessenwarnung bleibt bestehen. PingCastle-Importgrenzen 2026-09-09 (Phase 13A/13C): Verifiziert sind das freigegebene Dummy-Paar und synthetische Varianten, nicht jede PingCastle-Version. Ohne native Zaehler kann kein rechnerischer Vollstaendigkeitsnachweis erfolgen. Doppelte IDs werden diagnostiziert, bleiben aber gemaess bisheriger Entscheidung ein Finding mit erster Darstellung; es findet keine automatische Zusammenfuehrung widerspruechlicher Darstellungen statt. Der bekannte fehlende HTML-Anker und die XML-Results-Einschraenkung bleiben sichtbare Warnungen. Generische XML-Attribute sind keine automatische Assetklassifikation; semantisches XML-Mapping und Control-Path-JSON bleiben offen. Alte Importe benoetigen Reimport. Keine Schema-/Datenmigration, optionale PostgreSQL-Integration nicht aktiviert; vorhandene Client-Bundlegroessenwarnung bleibt bestehen.
Maester-Importgrenzen 2026-09-09 (Phase 13A/13C): Die statische Datengrammatik und der lexikalische Zuweisungsfinder sind kein allgemeiner JavaScript-Parser. Neue dynamische Exportvarianten benoetigen eine ausdrueckliche Erweiterung mit synthetischen Ablehnungs- und Vollstaendigkeitstests; ihre Ausfuehrung bleibt verboten. Verifiziert sind die bereitgestellten Dummy-Dateien und synthetische Varianten, nicht pauschal jede Maester-Version. Bekannte Zaehlerabweichungen markieren die Verarbeitung mit Warnungen, obwohl saemtliche Einzeltests erhalten bleiben. Bereits gespeicherte Importe erhalten weder die korrigierte Evidenzabbildung noch neuen HTML-Kontext automatisch. Excel und HTML bleiben eigenstaendige Quellen mit eigener Provenienz; gleichzeitiger Import beider Dateien erzeugt weiterhin separate Vorkommen und keine automatische dateiuebergreifende Deduplizierung. Der Maester-Laufzeitrechner darf nicht als bewertetes Zielsystem in die Assetzaehlung gelangen. Keine Persistenz- oder Schemaaenderung; optionale PostgreSQL-Integrationstests wurden nicht aktiviert. Die bestehende Client-Bundlegroessenwarnung bleibt offen. Maester-Importgrenzen 2026-09-09 (Phase 13A/13C): Die statische Datengrammatik und der lexikalische Zuweisungsfinder sind kein allgemeiner JavaScript-Parser. Neue dynamische Exportvarianten benoetigen eine ausdrueckliche Erweiterung mit synthetischen Ablehnungs- und Vollstaendigkeitstests; ihre Ausfuehrung bleibt verboten. Verifiziert sind die bereitgestellten Dummy-Dateien und synthetische Varianten, nicht pauschal jede Maester-Version. Bekannte Zaehlerabweichungen markieren die Verarbeitung mit Warnungen, obwohl saemtliche Einzeltests erhalten bleiben. Bereits gespeicherte Importe erhalten weder die korrigierte Evidenzabbildung noch neuen HTML-Kontext automatisch. Excel und HTML bleiben eigenstaendige Quellen mit eigener Provenienz; gleichzeitiger Import beider Dateien erzeugt weiterhin separate Vorkommen und keine automatische dateiuebergreifende Deduplizierung. Der Maester-Laufzeitrechner darf nicht als bewertetes Zielsystem in die Assetzaehlung gelangen. Keine Persistenz- oder Schemaaenderung; optionale PostgreSQL-Integrationstests wurden nicht aktiviert. Die bestehende Client-Bundlegroessenwarnung bleibt offen.
@ -2441,6 +2494,12 @@ Festgelegtes Review-Persistenzrisiko vom 2026-09-06: Dieselbe Finding-Occurrence
Festgelegtes Datenschutzrisiko vom 2026-09-06: Scanner-Zusatzinformationen und rohe Policy-Beobachtungen koennen Personen-, Konto-, Domain-, Host-, GPO-, Pfad- oder andere kundenidentifizierende Werte enthalten. Gegenmassnahme: Die Result-DB-Projektion arbeitet fail-closed mit einer versionierten Allowlist und engen Wertvalidatoren, verwendet kontrollierte Metadaten statt Quelllabels und entfernt Identitaeten, Originalwerte, Attribute und Locator. Der JSONL-Export prueft die Klassifikation erneut. Neue Tools liefern weiterhin kanonische Findings, aber keine ungeprueften Zusatzfelder; deren Freigabe erfordert eine synthetisch regressionsgepruefte Policy-Erweiterung. Festgelegtes Datenschutzrisiko vom 2026-09-06: Scanner-Zusatzinformationen und rohe Policy-Beobachtungen koennen Personen-, Konto-, Domain-, Host-, GPO-, Pfad- oder andere kundenidentifizierende Werte enthalten. Gegenmassnahme: Die Result-DB-Projektion arbeitet fail-closed mit einer versionierten Allowlist und engen Wertvalidatoren, verwendet kontrollierte Metadaten statt Quelllabels und entfernt Identitaeten, Originalwerte, Attribute und Locator. Der JSONL-Export prueft die Klassifikation erneut. Neue Tools liefern weiterhin kanonische Findings, aber keine ungeprueften Zusatzfelder; deren Freigabe erfordert eine synthetisch regressionsgepruefte Policy-Erweiterung.
Festgelegtes Result-DB-/LLM-Risiko vom 2026-09-22: Selbst nach Musterredaktion koennen freie Titel, Beschreibungen, Kategorien, Baselinebezeichnungen oder scheinbar technische Werte unbekannte Personen- oder Firmennamen tragen; Regex-Sanitisierung ist deshalb keine ausreichende Datenschutzgrenze. Gegenmassnahme: Keine importierte Freitextzeichenkette wird in der normalen Result DB repariert oder teilweise freigegeben. Sichtbare Texte werden ausschliesslich aus kontrollierten Konstanten und katalogeigenen oeffentlichen IDs neu aufgebaut, Kundenwerte komplett verworfen und unbekannte Quellen generisch benannt. Suche und Facetten arbeiten auf derselben bereits reduzierten Projektion. Jede kuenftige fachliche Erweiterung muss einen synthetischen Leckagetest fuer UI/API und JSONL ergaenzen. Restrisiko: Auch anonymisierte Findings beschreiben den Sicherheitszustand des ausgewaehlten Unternehmensscopes; wer keinerlei Unternehmenssicherheitsdaten an ein externes LLM uebermitteln darf, darf auch diesen Export nicht verwenden.
Risikokorrektur 2026-09-22: Die vorstehende vollstaendige Freitextsperre schuetzte zwar maximal, zerstoerte aber den beabsichtigten LLM-Nutzen und ist fuer bekannte Scannerprofile durch Policy 2.1.0 ersetzt. Regelprosa wird nur bei explizit bekannten Profilen freigegeben und zuvor aus privaten Begleitfeldern sowie festen Identifierklassen redigiert; unbekannte/generische Quellen bleiben gesperrt. Restrisiko bleibt, dass ein nicht als Identifier erkennbarer Eigenname ohne Rechtsformsuffix ausschliesslich in der Regelprosa stehen kann. Deshalb ist der Export eine technische Datenminimierung, keine automatische rechtliche Freigabe fuer beliebige externe LLMs; neue Profile benoetigen synthetische Leckage- und Nutzbarkeitstests.
Risikokorrektur 2026-09-23 fuer GPO-Vergleiche: Der Nutzer hat die vollstaendige Pseudonymisierung technischer GPO-Werte als fachlich unbrauchbar verworfen. Disclosure Policy 2.2.0 gibt deshalb im internen Result-DB-Scope und im bewusst ausgeloesten JSONL-Export konkrete konfigurierte und erwartete GPO-Werte einschliesslich technischer Principals/SIDs frei. Projekt-, Assessment-, Run-, Artefakt-, Locator-, Review- und automatisch erzeugte Hashidentitaeten bleiben entfernt. Diese technische Evidenz kann kundenidentifizierend sein; Export oder Uebermittlung an externe LLMs ist keine automatische Datenschutzfreigabe und darf nur innerhalb der jeweils zulaessigen Vertrauensgrenze erfolgen. Die App erfindet keine GPO-Anwendung oder effektive Endpunktwirkung.
Offen 2026-09-06 (Phase 13B): Die automatische Kontextableitung unterstuetzt derzeit belegte DC-Betriebssysteme; umfassende Member-/Client-/Produktableitung und kontextspezifische Baseline-Teilpakete sind nicht abgeschlossen. Ausgewaehlte/unvollstaendige Backups bleiben nach der bestehenden Bewertungsgrenze Review; ein neutraler Teilmengenvergleich ohne Vollstaendigkeitsaussage ist separat zu entwerfen. Fehlende Baselineoriginale verhindern Reprocessing aus Originalbytes, auch wenn alte Analysen noch lesbar sind. Erforderlich sind Wiederbereitstellung der identischen Originale oder ein neuer expliziter Import; keine Ersatzdaten und kein stiller Speicher-/Datenbankwechsel. Offen 2026-09-06 (Phase 13B): Die automatische Kontextableitung unterstuetzt derzeit belegte DC-Betriebssysteme; umfassende Member-/Client-/Produktableitung und kontextspezifische Baseline-Teilpakete sind nicht abgeschlossen. Ausgewaehlte/unvollstaendige Backups bleiben nach der bestehenden Bewertungsgrenze Review; ein neutraler Teilmengenvergleich ohne Vollstaendigkeitsaussage ist separat zu entwerfen. Fehlende Baselineoriginale verhindern Reprocessing aus Originalbytes, auch wenn alte Analysen noch lesbar sind. Erforderlich sind Wiederbereitstellung der identischen Originale oder ein neuer expliziter Import; keine Ersatzdaten und kein stiller Speicher-/Datenbankwechsel.
Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projektnamen oder spontane Portausweichungen koennen mehrere auseinanderlaufende ASSESSOR-Datenbanken erzeugen und die App unbemerkt mit einem anderen Datenstand verbinden. Gegenmassnahme: `assessor-2_0` und `assessor-2_0_assessor-postgres-data` sind in Compose und `AGENTS.md` kanonisch gebunden; das Volume ist extern, Port `5432` verbindlich, alternative ASSESSOR-Datenbanken und Fallbackports sind untersagt. Vor Persistenzmigrationen oder destruktiven Eingriffen ist ein verifiziertes Backup erforderlich; fremde Docker-Bestaende bleiben unberuehrt. Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projektnamen oder spontane Portausweichungen koennen mehrere auseinanderlaufende ASSESSOR-Datenbanken erzeugen und die App unbemerkt mit einem anderen Datenstand verbinden. Gegenmassnahme: `assessor-2_0` und `assessor-2_0_assessor-postgres-data` sind in Compose und `AGENTS.md` kanonisch gebunden; das Volume ist extern, Port `5432` verbindlich, alternative ASSESSOR-Datenbanken und Fallbackports sind untersagt. Vor Persistenzmigrationen oder destruktiven Eingriffen ist ein verifiziertes Backup erforderlich; fremde Docker-Bestaende bleiben unberuehrt.
@ -2683,8 +2742,39 @@ Verifiziert 2026-09-15 (Phase 13D, WSL-Qdrant): Docker in WSL erkannt, kanonisch
Implementiert 2026-09-15 (Phase 13D, Finding-Retrievalgrenze): Bestehende Unified Results ueber einen fail-closed Public-Signal-Query-Builder backendintern an den Regulatory Retriever angebunden; katalogeigene Maester-/PingCastle-Regel-IDs und fest definierte Technikthemen verhindern die Kopie von Rohbericht-, Asset-, Pfad-, Mandanten-, Review- und Provenienzdaten in Embeddingrequests. Versionierten begrenzten EN/DE-Evaluationsvertrag und Aggregat-CLI fuer HitRate@K, Recall@K und MRR hinzugefuegt. 1.283 synthetische Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Kein API-/UI-Pfad, keine Datenbankmutation, keine Mappingbehauptung und kein Reimport; Datenschutzfreigabe, Korpus und reale Dense-Baseline bleiben offen. Implementiert 2026-09-15 (Phase 13D, Finding-Retrievalgrenze): Bestehende Unified Results ueber einen fail-closed Public-Signal-Query-Builder backendintern an den Regulatory Retriever angebunden; katalogeigene Maester-/PingCastle-Regel-IDs und fest definierte Technikthemen verhindern die Kopie von Rohbericht-, Asset-, Pfad-, Mandanten-, Review- und Provenienzdaten in Embeddingrequests. Versionierten begrenzten EN/DE-Evaluationsvertrag und Aggregat-CLI fuer HitRate@K, Recall@K und MRR hinzugefuegt. 1.283 synthetische Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Kein API-/UI-Pfad, keine Datenbankmutation, keine Mappingbehauptung und kein Reimport; Datenschutzfreigabe, Korpus und reale Dense-Baseline bleiben offen.
Implementiert 2026-09-15 (Phase 13D): Qdrant-Compose-Service, versionierter regulatorischer Dokument-/Chunkvertrag, deterministischer Chunker, OpenAI-kompatibler mehrsprachiger Embeddingadapter, Collection-/Payload-Indexverwaltung, dokumentweiser Replace-Ingestion, gefiltertes Dense Retrieval sowie Candidate-/Reranker-Erweiterungsgrenzen und CLI-Ingestion hinzugefuegt. 1.275 synthetische Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. PostgreSQL-, Scanner- und UI-Vertraege bleiben unveraendert; keine Mappingbehauptung und kein Reimport. Docker fehlt auf dem Host, daher keine Compose-/Qdrant-/Embedding-Liveabnahme. Betriebs- und Sicherheitsgrenzen in `docs/REGULATORY_RAG.md` dokumentiert. Implementiert 2026-09-15 (Phase 13D): Qdrant-Compose-Service, versionierter regulatorischer Dokument-/Chunkvertrag, deterministischer Chunker, OpenAI-kompatibler mehrsprachiger Embeddingadapter, Collection-/Payload-Indexverwaltung, dokumentweiser Replace-Ingestion, gefiltertes Dense Retrieval sowie Candidate-/Reranker-Erweiterungsgrenzen und CLI-Ingestion hinzugefuegt. 1.275 synthetische Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. PostgreSQL-, Scanner- und UI-Vertraege bleiben unveraendert; keine Mappingbehauptung und kein Reimport. Docker fehlt auf dem Host, daher keine Compose-/Qdrant-/Embedding-Liveabnahme. Betriebs- und Sicherheitsgrenzen in `docs/REGULATORY_RAG.md` dokumentiert.
2026-09-23: Vorherigen Abschluss 2.5.11 fuer den gemeldeten PingCastle-Fall korrigiert (Phase 7/13C und 12/13B). Getrennte HTML-/XML-Vorkommnisse derselben Regel werden in der Work-Leseprojektion berichtsspezifisch abgeglichen; oeffentlich dokumentierte PingCastle-IDs werden in der Result DB mit nativer Gross-/Kleinschreibung sichtbar. Unbekannte IDs bleiben fail-closed, widerspruechliche Titel werden nicht erraten. Synthetische Work-/Result-DB-Regressionen, kein Kundendatenzugriff, keine Migration und kein Reimport. Patchversion 2.5.12, Disclosure Policy 2.5.0.
2026-09-23: Scanner-Ueberschriften zwischen Result DB und Work vereinheitlicht (Phase 7/13C). Work verwendet in Aggregat- und Quellansicht den kanonischen Definitionstitel, den auch die Result DB fuehrt; ein abweichender nativer Scannertext bleibt als `Native source heading` sichtbar und im kanonischen Original unveraendert. Eine synthetische PingCastle-Regression prueft den konkreten regelorientierten Quelltitel gegen den besser zuordenbaren anzahlbezogenen Result-DB-Titel in beiden Work-Pfaden. 42 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden; Gesamtsuite 1.448 bestanden, 13 optionale Skips und fuenf bekannte fachfremde Vertragsfehler. Keine Migration, Reimporte oder Bestandsmutation. Produktversion 2.5.11.
2026-09-23: Disclosure Policy 2.4.0 fuer LLM-faehige GPO-Ergebnisse umgesetzt (Phase 12/13B). Technische Settingidentitaet, Pfad, Operation, Typ, Ist-/Sollwert, Vergleich, oeffentliche Baseline, Diagnose, Aussagegrenze und Verarbeitung bleiben nutzbar; Kunden-/Projekt-/Domain-/GPO-/Backup-/Datei-/Locator-/Zeit-/Reviewidentitaet und interne Hash-/Baseline-IDs entfallen. Wohlbekannte Principals bleiben erhalten, Domain-SIDs und konkrete Domainprincipals werden rekursiv und resultlokal pseudonymisiert, einschliesslich Baselinevarianten. Synthetische Disclosure-, sichtbare Result-Row- und JSONL-Regressionen pruefen Nutzbarkeit und Abwesenheit der Identitaeten. 70 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden; Work/GPO Analysis und kanonische Originale bleiben unveraendert. Keine Migration, Reimporte oder Bestandsmutation. Produktversion 2.5.10.
2026-09-23: Security-Domain-Klassifikation auf eine gestufte, quellengestuetzte Evidenzlogik erweitert: Kernfelder und Source Rule ID zuerst, danach technische Erklaerung, Rationale, Empfehlung, Remediation, Loesung, Impact und Details nur bei einer eindeutigen Domain. `Unclassified` bleibt bei fehlender oder widerspruechlicher Evidenz erhalten, damit keine Kategorie erfunden wird. Das allgemeine Vokabular umfasst Spooler-Haertung; `A-DC-Spooler` ist nur ein Regressionstest, keine Sonderlogik (Phase 7/13C). Die zentrale Work-Suche wurde zugleich gegen gemischte Gross-/Kleinschreibung sowie Rand-Leerzeichen gehaertet. 44 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden; synthetische Regressionen verwenden ausschliesslich Dummywerte. Keine Import-, Persistenz-, Score-, Reimport- oder Bestandsdatenmutation. Produktversion 2.5.9.
2026-09-23: Source Rule ID als gemeinsame sichtbare Scanneridentitaet in Result DB und Work ergaenzt (Phase 7/13C, Disclosure Policy 2.3.0). Result DB traegt eine feste Spalte; Work benennt das kanonisch zugeordnete Quellfeld in der Leseprojektion einheitlich und zeigt es einmal im Finding Reader. Originalfelder bleiben erhalten, leere IDs werden nicht erfunden, GPO-Setting-Identitaeten bleiben fachlich getrennt bezeichnet. Der Scope-Fall fuer Purple Knight AD/Entra/kombiniert behaelt nun ebenfalls gueltige `SI...`-IDs. 133 fokussierte Tests, beide Produktionsbuilds, Local-only- und Designpruefung sowie die ausschliessliche Dummy-Live-Abnahme in beiden Themes bestanden. Gesamtsuite: 1.444 bestanden, 13 optionale Skips und fuenf bekannte fachfremde Vertragsfehler. Keine Bestandsmutation. Produktversion 2.5.8.
2026-09-23: GPO-Vergleiche in Result DB wieder fachlich nutzbar gemacht und Purple Knight for Entra ID als eigenen Result-DB-Quellenscope eingefuehrt (Phase 7/13C, Disclosure Policy 2.2.0). Konkreter Settingtitel/Pfad, Ist-/Sollwert, Baselinevarianten und lesbare technische IDs ersetzen generische Vergleichstitel und leere Detailfelder; interne Hash-IDs entfallen aus der Anzeige. AD, Entra ID und kombinierte Purple-Knight-Laeufe bleiben ein Profil, sind aber getrennt filterbar und lesbar bezeichnet. Historische unspezifische Laeufe werden nicht heuristisch umklassifiziert. Synthetische UI-, Domain-, Export-, Persistenz- und Serverregressionen sowie Live-Abnahme nur auf `Dummy`; keine Bestandsmutation. Produktversion 2.5.7.
2026-09-22: Maester-Darstellung im Work-Finding-Reader an die native Informationshierarchie angeglichen (Phase 7/13C). Die Ueberschrift verwendet `Title` ohne doppeltes ID-Praefix; die ID bleibt als eigene technische Zeile sichtbar, gefolgt von Test result, Test details, Category, Tags und Source. Technische Ausfuehrungsfehler bleiben eingeklappt, waehrend doppelte Index-/Dauer-/Rohfelder entfallen. Statuswerte werden nicht mehr als fehlende Results-Dateiverweise behandelt. `CISA.MS.EXO.17.3` ist als exakte oeffentliche Maester-Regel hinterlegt und fuehrt die AI-Suche zu Microsoft Purview Audit Retention statt Windows Audit Policy. Read-only Bestandspruefung und 83 fokussierte Tests sowie beide Produktionsbuilds bestanden; keine Quellwert-, Import-, Persistenz- oder Bestandsaenderung, kein Reimport.
2026-09-22: PingCastle-Domainzuordnung fuer neun eindeutige Work-Findings erweitert (Phase 7/13C). Obsolete Betriebssysteme, Hardened Paths, Script Engines und CVEs werden System Hardening, LDAP-/TLS-Bindings Authentication & Access sowie OU-Loeschschutz und NTFRS/SYSVOL AD Infrastructure & PKI zugeordnet. Ein synthetischer Verhaltenstest verlangt fuer exakt diese Titel null `Unclassified`; 28 fokussierte Domain-, Sidebar- und Filtertests sowie beide Produktionsbuilds bestanden. Keine Import-, Status-, Score-, Persistenz- oder Reimportaenderung.
2026-09-22: Data-Hub-Reimporthinweise den betroffenen Scans direkt zugeordnet (Phase 7/13B). Der timeslotweite Sammelblock wurde durch eine flache Hinweiszeile unmittelbar unter jeder betroffenen Toolzeile ersetzt; Erklaerung und Reimport-/Uebernahmeaktion bleiben quellenlokal. Responsive Anordnung und synthetischer Quelltext-Verhaltenstest ergaenzt. 61 fokussierte Tests, beide Produktionsbuilds und die Live-Pruefung des Liftket-Timeslots bestanden; ASSESSOR 2.5.6 wird nach gezieltem Neustart von App und PostgreSQL-Backend sichtbar ausgeliefert. Keine Import-, Review-, Persistenz- oder Ersatzsemantikaenderung.
2026-09-22: Maester-`Unclassified` fuer stabile Suitekennungen korrigiert (Phase 7/13C). ORCA/EXO und Entra-Suiten werden in der gemeinsamen Work-/Client-Info-Projektion als enger Fallback `Cloud & Applications` zugeordnet; beschreibende Domainregeln behalten Vorrang. Der synthetische Work-Verhaltenstest deckt ORCA.104, EIDSCA und Conditional Access samt Facettenfilter ab. Keine Persistenz-, Import-, Score- oder Reimportaenderung.
2026-09-22: Produktversion von 2.5.5 auf 2.5.6 angehoben und die kuenftige Versionspflege in `AGENTS.md` verankert. Die zentrale Paketversion, Lockdatei sowie Header-/About- und Health-API-Vertragstests sind synchron; nach jedem materiellen verifizierten Umsetzungspaket ist Patch/Minor/Major bewusst neu zu bewerten. Historische Berichte bleiben unveraendert. Patch-Release, keine Schema-, API- oder Bestandsdatenmigration.
2026-09-22: Nutzerkorrektur zur Scanneridentitaet abgesichert (Phase 13A/13B). `Purple Knight for Active Directory` bleibt in Import, ScanRun, Umgebungsbeobachtungen, Scoring und Darstellung ausschliesslich Purple Knight und ist niemals Maester oder Entra ID. Der irrefuehrend nach Maester benannte Kompatibilitaetsexport der inzwischen werkzeuguebergreifenden Tenant-Zaehlung wurde entfernt. Ein synthetischer End-to-End-Regressionstest prueft Tool-ID, Anzeigename und das Ausbleiben jedes `maester.*`-/`entra.*`-Kontexts; 53 fokussierte Tests gruen. Keine Migration oder Bestandsmutation.
2026-09-22: Purple Knight for Entra ID als eigener Assessment-Scope umgesetzt (Phase 13A/13B). Profil 1.4.0 erkennt die Entra-Summary inhaltsbasiert, persistiert Tenant, nativen Posture-Score, Zaehler und Kategoriescores und bezeichnet ScanRun/Scorequelle getrennt von Purple Knight for Active Directory. Client Info zaehlt und zeigt den Tenant aus der gemeinsamen Environment-Grenze. Synthetische Entra-Summary prueft Erkennung, Provenienz, Scanlabel und nativen Score; zusammen 109 fokussierte Tests und beide Builds gruen. Read-only Bestandsdiagnose: der aktive Liftket-Entra-Lauf besitzt 56 Findings und eine erhaltene generische Summary, aber keine kanonischen Environment-Beobachtungen. Kein stiller Bestandsumbau; Reimport der nicht gespeicherten Originaldateien erforderlich. Keine Migration oder Bestandsmutation.
2026-09-20: Maester-Import mit nativem Unicode-Nullzeichen repariert (Phase 13A/13C). Die reale PostgreSQL-Fehlermeldung belegte `unsupported Unicode escape sequence` fuer `\\u0000` im Finding-JSONB. `escapeJsonUnicodeNulls` arbeitet rekursiv an der kanonischen Modellgrenze und repraesentiert jedes Vorkommen sichtbar als `\\u0000`; `unicode-null-escaped` dokumentiert Anzahl und Darstellung pro Importdatei. Der Auto-Import behaelt eine bereits erfolgreiche Vorschau bei einem Abschlussfehler, statt eine leere Fehler-Vorschau zu erzeugen. 26 fokussierte Maester-/HTTP-Tests, beide Produktionsbuilds, Local-only- und Designpruefung erfolgreich; der einmal parallel gestartete zusaetzliche npm-Testlauf traf einen Windows-`spawn EPERM`, die identischen fokussierten Tests liefen davor und danach erfolgreich. Keine Migration oder Bestandsdatenmutation; laufender Backendprozess benoetigt den aktualisierten Build. 2026-09-20: Maester-Import mit nativem Unicode-Nullzeichen repariert (Phase 13A/13C). Die reale PostgreSQL-Fehlermeldung belegte `unsupported Unicode escape sequence` fuer `\\u0000` im Finding-JSONB. `escapeJsonUnicodeNulls` arbeitet rekursiv an der kanonischen Modellgrenze und repraesentiert jedes Vorkommen sichtbar als `\\u0000`; `unicode-null-escaped` dokumentiert Anzahl und Darstellung pro Importdatei. Der Auto-Import behaelt eine bereits erfolgreiche Vorschau bei einem Abschlussfehler, statt eine leere Fehler-Vorschau zu erzeugen. 26 fokussierte Maester-/HTTP-Tests, beide Produktionsbuilds, Local-only- und Designpruefung erfolgreich; der einmal parallel gestartete zusaetzliche npm-Testlauf traf einen Windows-`spawn EPERM`, die identischen fokussierten Tests liefen davor und danach erfolgreich. Keine Migration oder Bestandsdatenmutation; laufender Backendprozess benoetigt den aktualisierten Build.
2026-09-22: Nutzbarkeit der LLM-faehigen Result DB nach Nutzerkorrektur wiederhergestellt (Phase 12/13B). Disclosure Policy 2.1.0 behaelt bei CIS-CAT, Maester, PingCastle und Purple Knight redigierte Regel-Titel, Beschreibungen und Empfehlungen; Purple Knight behaelt validierte `SI`-IDs. Typisierte Platzhalter ersetzen identifizierende Inhalte, Assets/Reviewpersonen/Scope-/Provenienzfelder bleiben entfernt, HTML-Regelprosa wird Klartext. Ein synthetischer ResultRow-Test sichert das konkrete Aufklappen mit sichtbarer Empfehlung und verborgenem Konto. 45 fokussierte Tests bestanden. Live: 530/530 Purple-Knight-Zeilen mit aussagekraeftigem Titel, Beschreibung und SI-ID, 501 mit Empfehlung, null Asset-Wiederholungen sowie null E-Mail-/UPN-, Domain-SID- oder Firmensuffixmuster. Keine Migration, Reimporte oder Bestandsmutation.
2026-09-22: Allgemeine LLM-Datenschutzgrenze der Result DB umgesetzt (Phase 12/13B). `result-db-disclosure` 2.0.0 reduziert Scanner-, GPO- und Environment-Zeilen fail-closed auf kontrollierte Metadaten und normalisierte Urteile; Repository/API, Suche, Facetten, Einzelzeile und ResultRow verwenden dieselbe Projektion, waehrend `internal-analysis` Originale fuer Work bewahrt. JSONL 2.0.0 entfernt zusaetzlich alle internen Scope-/Result-/Run-/Artefakt-IDs, Zeitpunkte, Suchtexte und konkreten Filterwerte. Synthetische Regressionen enthalten absichtlich Person, Firmenname, UPN, Domain, SID, Pfad, GPO-/Backupidentitaet und Reviewperson und sichern deren Abwesenheit in Result-DB-UI und Export. 43 fokussierte Tests und beide Produktionsbuilds bestanden; Live-API ueber 14.762 PostgreSQL-Ergebnisse ohne E-Mail-/UPN-, domaenenspezifische SID-, private Zeit- oder gesperrte Inhaltsfelder. Keine Migration, Reimporte oder Bestandsmutation. Gesamtlauf nach Aenderung: 1.429 bestanden, 13 optionale Skips, fuenf bereits vorhandene fachfremde Quelltext-/UI-Vertragstests weiterhin rot; der einzige durch die Datenschutzgrenze betroffene GPO-Detailtest wurde anschliessend fokussiert gruen verifiziert.
2026-09-20: Mengenbalken fuer die Security-Domain-Verteilung in Client Info umgesetzt und nach Nutzerkorrektur von Text und Zahl getrennt sowie mit dem Overall-Score-Donut farblich gekoppelt (Phase 7). `DashboardDomainRow` verwendet seinen ohnehin sichtbaren Abweichungs- oder Review-only-Wert; `DashboardPanel` bildet einmal die Summe der angezeigten Domains und uebergibt sie als Nenner. Ein gemeinsames Vier-Spalten-CSS-Subgrid bemisst Score, laengsten Domainnamen, Balkenflaeche und breiteste gerenderte Zahl. Das dekorative `aria-hidden` Balkenelement belegt ausschliesslich die dritte Spalte, endet bei jeder Zeile an derselben Kante und waechst nach links. `dashboardScoreTone` setzt denselben Overall-Tone am Posture-Bereich und am Donut; alle Balken lesen dessen `--chart-color` exakt, waehrend Domainknoten ihre eigene Tone-Klasse behalten. Ein synthetischer 3:1-Fall sichert exakt `75 %`/`25 %`, alle acht Katalogzeilen sowie Subgrid- und gemeinsame Tone-Geometrie. 23 fokussierte Tests, Designsprachpruefung und Client-/Server-Produktionsbuild bestanden. Live am Dummy: neun getrennte Zeilen, je rund 12 px zwischen Text/Balken und Balken/Zahl, Donut und alle Balken exakt `rgb(232, 33, 79)` in Light/Dark, keine Ueberlaeufe; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema-, Klassifikations-, Score- oder Drilldownmutation. 2026-09-20: Mengenbalken fuer die Security-Domain-Verteilung in Client Info umgesetzt und nach Nutzerkorrektur von Text und Zahl getrennt sowie mit dem Overall-Score-Donut farblich gekoppelt (Phase 7). `DashboardDomainRow` verwendet seinen ohnehin sichtbaren Abweichungs- oder Review-only-Wert; `DashboardPanel` bildet einmal die Summe der angezeigten Domains und uebergibt sie als Nenner. Ein gemeinsames Vier-Spalten-CSS-Subgrid bemisst Score, laengsten Domainnamen, Balkenflaeche und breiteste gerenderte Zahl. Das dekorative `aria-hidden` Balkenelement belegt ausschliesslich die dritte Spalte, endet bei jeder Zeile an derselben Kante und waechst nach links. `dashboardScoreTone` setzt denselben Overall-Tone am Posture-Bereich und am Donut; alle Balken lesen dessen `--chart-color` exakt, waehrend Domainknoten ihre eigene Tone-Klasse behalten. Ein synthetischer 3:1-Fall sichert exakt `75 %`/`25 %`, alle acht Katalogzeilen sowie Subgrid- und gemeinsame Tone-Geometrie. 23 fokussierte Tests, Designsprachpruefung und Client-/Server-Produktionsbuild bestanden. Live am Dummy: neun getrennte Zeilen, je rund 12 px zwischen Text/Balken und Balken/Zahl, Donut und alle Balken exakt `rgb(232, 33, 79)` in Light/Dark, keine Ueberlaeufe; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema-, Klassifikations-, Score- oder Drilldownmutation.
2026-09-20: Verbindliche 14-/16-px-Lesetypografie in Client Info umgesetzt (Phase 7, Nutzerentscheidung). `AGENTS.md` unterscheidet nun primaere Informationslabels und strukturierte Tabellen-/Kachelinhalte mit mindestens 14 px von lesbaren Werten und Fliesstext mit 16 px; nur Controls, technische Metadaten und Badges duerfen kompakter bleiben. Environment Detail verwendet die Rollen fuer Faktlabels/-werte, Source-Overview-Metadaten, Operating-systems-Hinweis, Gruppen, Tabellenkoepfe und Quellspalten. Synthetische CSS-Vertragstests sichern die Rollen. 23 fokussierte Tests, Designsprachpruefung und Client-/Serverbuild erfolgreich. Live in Light und Dark: `Domain` 14 px/700, `sevenkingdoms.local` 16 px/400, Source-Information und OS-Tabelle 14 px, kein horizontaler Ueberlauf der Directory-Kachel; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema- oder Bewertungsmutation. 2026-09-20: Verbindliche 14-/16-px-Lesetypografie in Client Info umgesetzt (Phase 7, Nutzerentscheidung). `AGENTS.md` unterscheidet nun primaere Informationslabels und strukturierte Tabellen-/Kachelinhalte mit mindestens 14 px von lesbaren Werten und Fliesstext mit 16 px; nur Controls, technische Metadaten und Badges duerfen kompakter bleiben. Environment Detail verwendet die Rollen fuer Faktlabels/-werte, Source-Overview-Metadaten, Operating-systems-Hinweis, Gruppen, Tabellenkoepfe und Quellspalten. Synthetische CSS-Vertragstests sichern die Rollen. 23 fokussierte Tests, Designsprachpruefung und Client-/Serverbuild erfolgreich. Live in Light und Dark: `Domain` 14 px/700, `sevenkingdoms.local` 16 px/400, Source-Information und OS-Tabelle 14 px, kein horizontaler Ueberlauf der Directory-Kachel; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema- oder Bewertungsmutation.

View File

@ -0,0 +1,266 @@
# Umsetzungsauftrag 2: Auflösung der Maester-Tag-Liste
Adressat: Codex. Verbindlich und abschließend formuliert.
Wo dieser Auftrag schweigt, gelten `AGENTS.md`, `docs/REIMPORT_POLICY.md`,
`docs/TOOL_PROFILES.md` und `docs/REPORT_STRATEGY.md` unverändert weiter.
Bei einem Widerspruch: **anhalten und melden, nicht eigenmächtig auflösen.**
Verhältnis zu Auftrag 1 (`docs/REPORT_IMPLEMENTATION_BRIEF.md`): unabhängig
umsetzbar. Nur AP5 dieses Auftrags setzt voraus, dass Auftrag 1 abgeschlossen ist.
## 0. Befund (verifiziert am Client Dummy)
Der Maester-Export liefert **zwei** als `category` gemappte Spalten:
- eine Einzelwert-Spalte — im aktuellen Dummy-Lauf `Maester/Entra` (74),
`CISA` (73), `ORCA` (67), `EIDSCA` (44), `CIS` (25), `Exposure Management` (10),
`Maester/Exchange` (9), `Maester/Teams` (6);
- eine **Tag-Listen-Spalte**, die als **eine** Zeichenkette übernommen wird, zum
Beispiel `CIS.M365.1.1.1, L1, CIS E3 Level 1, CIS E3, CIS, Security, CIS M365 v5.0.0`
oder `MS.AAD, MS.AAD.3.4, CISA.MS.AAD.3.4, CISA, Security, Entra ID P1`.
Folgen im Bestand:
- `assessor_result_categories` enthält **1.236** Maester-Einträge gegenüber 27 für
Purple Knight und 17 für PingCastle. Nahezu jeder Test erzeugt eine eigene,
unteilbare Kategorie. Als Facette ist das unbrauchbar.
- Es existieren **zwei Serialisierungen** derselben Liste: die kommagetrennte
Form und eine JSON-Array-Textform (`["CIS.M365.1.1.1","L1",…]`).
- `scannerResultCategories` in `src/domain/unified-result.ts` zerlegt bereits
echte JSON-Arrays, aber **keine** Zeichenketten.
- Die daraus erzeugten Kategorien werden **bei der Ingestion persistiert**
(`server/postgres/postgres-assessor-repository.ts:222` → `INSERT INTO
assessor_finding_occurrence_categories`). Sie sind keine Lesezeit-Projektion.
Daraus folgt die Einordnung in Abschnitt 2.
## 1. Ziel
Eine Tag-Liste wird in ihre einzelnen Tags zerlegt, **ausschließlich dort, wo das
Profil es ausdrücklich erklärt hat**. Damit werden `CISA`-SCuBA-IDs
(`MS.AAD.3.4`), CIS-M365-IDs, Benchmark-Versionen und die vorausgesetzte
Lizenzstufe (`Entra ID Free` / `P1` / `P2`) als einzeln auswähl- und zählbare
Kategorien verfügbar. Es wird nichts geraten und nichts interpretiert.
## 2. Reimport-Einordnung (bindend)
Die erzeugten Kategorien sind persistierte Fakten, nicht Darstellung. Diese
Änderung fällt damit unter „field-role mapping … or canonical projection" in
`docs/REIMPORT_POLICY.md` und ist **reimport-pflichtig**:
- append-only neuer Eintrag in `PROCESSING_VERSION_MANIFESTS`;
- `reimportRequired: true`;
- bestehende Maester-Importe werden als reimport-bedürftig markiert;
- historische Einträge und bereits persistierte Werte werden **nicht** umgeschrieben.
Diese Einordnung ist nicht verhandelbar. Sie darf nicht als presentation-only
umdeklariert werden, auch nicht, um einen Reimport zu vermeiden.
## 3. Arbeitspakete
### AP1 — Deklarative Trennzeichen im Profilvertrag
**Datei:** `src/domain/tool-profile-definition.ts`
Optional auf `ToolProfileDefinition`:
```ts
categoryLists?: {
version: string;
/** Exakte Trennzeichen. Leer oder fehlend bedeutet: nicht trennen. */
separators: string[];
/** Warum diese Quelle Listen liefert und welche Spalte sie trägt. */
reason: string;
};
```
Bindend:
- Fehlt das Feld, bleibt das heutige Verhalten **bit-identisch**. Kein Profil
wird implizit betroffen.
- Nur `src/profiles/maester/maester-definition.ts` deklariert es:
`separators: [',', ';', '|']`, mit einer Begründung, die die Tag-Spalte benennt.
- Purple Knight, PingCastle, CIS-CAT und Generic deklarieren es **nicht** und
erhalten **keinen** Manifest-Eintrag aus diesem Auftrag.
- `tests/profiles/declarative-profiles.test.ts` prüft die Deklaration mit:
Trennzeichen sind nicht leer, paarweise verschieden, einzelne Zeichen, und
`reason` ist nicht leer.
### AP2 — Zerlegung in `scannerResultCategories`
**Datei:** `src/domain/unified-result.ts`
`scannerResultCategories` erhält den Profilvertrag als zusätzliche Eingabe und
zerlegt nach dieser Reihenfolge:
1. Ist `normalizedValue` ein echtes JSON-Array: bestehendes Verhalten von
`categoryStrings`, ein Eintrag je Element. **Unverändert.**
2. Ist `normalizedValue` eine Zeichenkette, die mit `[` beginnt und über
`JSON.parse` zu einem **Array ausschließlich aus Zeichenketten** wird: diese
Elemente verwenden. Schlägt das Parsen fehl oder ist das Ergebnis kein solches
Array, gilt Schritt 3. Kein `eval`, keine Reparatur, keine Teilauswertung.
3. Ist für dieses Profil `categoryLists` deklariert: an den deklarierten
Trennzeichen zerlegen. Sonst: Zeichenkette unverändert übernehmen.
Anschließend für alle Wege gleich: trimmen, leere Werte verwerfen, Duplikate
unter Beibehaltung des **ersten** Vorkommens entfernen.
Reihenfolge und `isPrimary`:
- Reihenfolge ist: `definition.category`, danach die `category`-Mappings in ihrer
bestehenden Reihenfolge, darin die Tags in ihrer Reihenfolge in der Quelle.
- `isPrimary` bleibt genau der **erste** Eintrag dieser Gesamtreihenfolge.
Originalwerte:
- Die `fieldMappings` der Occurrence bleiben **unverändert**. Die ungeteilte
Original-Liste bleibt dort vollständig erhalten und ist weiterhin die
Provenienz. Es wird **kein** zusätzlicher Kategorieeintrag angelegt, der die
ungeteilte Liste wiederholt.
Diagnostik:
- Liefert ein nicht leerer Quellwert nach der Zerlegung null Tags, wird eine
Import-Diagnose erzeugt. Der Wert wird nicht still verworfen.
### AP3 — Processing-Manifest
**Dateien:** `src/profiles/maester/maester-definition.ts`,
`src/domain/processing-compatibility.ts`
- Maester `version` `1.1.0` → `1.2.0`, `processing.mappingVersion` `1.1.0` → `1.2.0`.
- `normalizationVersion` und `presentationVersion` bleiben unverändert.
- Neuer, **angehängter** Manifest-Eintrag: `profileId: 'maester'`,
`profileVersion: '1.2.0'`, `mappingVersion: '1.2.0'`,
`changeClass: 'compatible'`, `reimportRequired: true`,
`affectedDimensions: ['mapping', 'presentation']`, `current: true`.
Der bisherige `1.1.0`-Eintrag wird auf `current: false` gesetzt und sonst
**nicht angefasst**.
- `affectedDimensions` enthält **nicht** `environment-context`; die
Kontexterfassung ändert sich nicht.
- `compatibilityKey` bleibt `maester-canonical-v1`.
- `CURRENT_ASSESSOR_PROCESSING_VERSION` wird **nicht** verändert, sofern dafür
keine gesonderte Entscheidung vorliegt.
### AP4 — Konsistenz der bestehenden Ansichten
Die Zerlegung erhöht die Kategorie-Kardinalität je Datensatz. Sicherzustellen ist:
- **Work `Category`-Facette:** bleibt funktionsfähig, zählt Tags statt Listen.
Die GPO-Kontextregeln aus `AGENTS.md` bleiben unverändert — `Category` bleibt
im GPO-Kontext permanent und steht direkt nach `Result Status`.
- **Security Domain:** die Zuordnung darf sich für einen unveränderten Datensatz
**nicht** ändern. Die Tag-Tokens sind dieselben, nur getrennt abgelegt.
Pflichttest mit synthetischem Fixture.
- **Pathfinder:** `buildFindingPathItems` in
`src/features/mitre-graph/mitre-graph.ts` zerlegt Kategoriefelder heute an
`/[;|]+/`, also **nicht** am Komma. Record-Pfad und Unified-Pfad müssen nach
dieser Änderung dieselbe Tag-Menge liefern. Abweichungen sind zu beheben,
nicht zu tolerieren.
- **Performance:** es gelten die Regeln aus `AGENTS.md`, Abschnitt *Navigation and
rendering performance*. Facetten werden weiterhin in einem gemeinsamen Durchlauf
gezählt; die Prüfung der Kategorie-Eignung bricht beim Überschreiten ihrer
Kardinalitätsgrenze ab, exakte Zähler eignungsfähiger Facetten bleiben erhalten.
### AP5 — Report-Abschnitt „Framework-Abdeckung"
**Voraussetzung:** Auftrag 1 ist abgeschlossen.
**Datei:** `src/exports/management-overview-report.ts`,
Renderer-Version auf `'1.2.0'`.
Neuer Abschnitt nach „Maßnahmenbündel", ausschließlich aus den nun einzeln
vorliegenden Kategorien:
- Abdeckung je erkanntem Framework-Präfix (`CIS.M365`, `MS.AAD` bzw. `CISA`),
jeweils erfüllt / offen / nicht geprüft, wobei `not_run` **eigene** Zeile ist
und niemals als erfüllt gezählt wird;
- Abweichungen, die eine Lizenzstufe voraussetzen, gruppiert nach der von der
Quelle genannten Stufe (`Entra ID Free`, `P1`, `P2`).
Bindend:
- Die Präfixe werden aus den Tags **gelesen**, nicht erfunden. Ein Tag ohne
erkanntes Präfix bleibt ungruppiert und wird als solcher ausgewiesen.
- Es entsteht **keine** Konformitätsaussage zu NIS2, BSIG oder KRITIS und
**keine** Kostenrechnung. Die Lizenzgruppierung ist eine Voraussetzungsangabe
der Quelle, keine Empfehlung und keine Beschaffungsaussage.
- Unbekanntes ist ein Geviertstrich, niemals `0`.
### AP6 — Reimport-Anzeige verifizieren
Ohne Codeänderung, falls die Ableitung greift: Data Hub muss einen Maester-Lauf
mit Profilversion `1.1.0` nach dieser Änderung als reimport-bedürftig ausweisen,
mit den betroffenen Dimensionen. Greift die Ableitung nicht, ist die Ursache zu
beheben — nicht das Manifest anzupassen.
Beim Reimport gilt der Preservation-Contract aus `docs/REIMPORT_POLICY.md`
unverändert: vollständige, kundeneigene Review-Objekte einschließlich
Accepted-Risk-Daten überleben über die eindeutige Scanner-/Regel-/Asset-Identität;
bei fehlender oder mehrdeutiger Zuordnung bleibt die alte Review am bisherigen
Lauf und der Ersatz meldet einen ungelösten Stand.
## 4. Harte Verbote
1. Keine Zerlegung ohne ausdrückliche Profil-Deklaration. Kein globales
Komma-Splitting, keine Heuristik über Feldnamen, keine Erkennung „sieht nach
Liste aus".
2. Keine Auswertung von JSON jenseits einer strikten `JSON.parse`-Prüfung auf ein
Array von Zeichenketten. Kein `eval`, keine Teilreparatur defekter Werte.
3. Keine Änderung, Normalisierung, Groß-/Kleinschreibung-Anpassung oder
Übersetzung der einzelnen Tags. Sie werden unverändert übernommen.
4. Keine Umdeklaration dieser Änderung als presentation-only und kein Verzicht
auf die Reimport-Markierung.
5. Kein Umschreiben historischer Manifest-Einträge, Occurrences,
`fieldMappings` oder bestehender Kategoriezeilen.
6. Keine Ableitung einer Severity, Priorität oder eines Rangs aus einem Tag —
auch nicht aus `L1`, `L2` oder einer Lizenzstufe. Die deklarierte
`priorityBasis` von Maester bleibt `severity` und unverändert.
7. Keine Aussage, dass ein Framework erfüllt sei, solange Tests `not_run` sind.
8. Keine echten Kunden-, Mandanten- oder Scan-Inhalte in Tests, Fixtures, Logs
oder Commit-Nachrichten. Ausschließlich synthetische Fixtures.
## 5. Tests (Pflicht, synthetisch)
| Paket | Testdatei | Muss nachweisen |
| --- | --- | --- |
| AP1 | Erweiterung `tests/profiles/declarative-profiles.test.ts` | Maester deklariert `categoryLists` gültig; alle übrigen Profile deklarieren es nicht |
| AP2 | `tests/domain/unified-result-category-lists.test.ts` | Kommaliste wird zerlegt; JSON-Array-Text wird zerlegt; defekter JSON-Text fällt auf die Zeichenkettenregel zurück; ein Profil ohne Deklaration liefert bit-identisch das bisherige Ergebnis; Trim, Leerwerte, Duplikate, Reihenfolge und `isPrimary`; `fieldMappings` bleiben unverändert; nicht leerer Wert ohne Tags erzeugt eine Diagnose |
| AP3 | Erweiterung `tests/domain/processing-compatibility.test.ts` | `1.2.0` ist `current` und `reimportRequired`; `1.1.0` bleibt inhaltlich unverändert und ist nicht mehr `current`; die Liste bleibt append-only |
| AP4 | `tests/features/category-facet-consistency.test.ts` | Security-Domain-Zuordnung für ein unverändertes Fixture identisch vor und nach der Zerlegung; Record- und Unified-Pfad liefern dieselbe Tag-Menge; Facettenzählung bleibt in einem Durchlauf und respektiert die Kardinalitätsgrenze |
| AP5 | Erweiterung `tests/exports/management-overview-report.test.ts` | Abschnitt vorhanden; `not_run` eigene Zeile und nie als erfüllt; unbekanntes Präfix ausgewiesen; keine Konformitäts- oder Kostenaussage im Output |
| AP6 | Verhaltenstest der Readiness-Ableitung | Ein Lauf mit Profilversion `1.1.0` wird als reimport-bedürftig mit den betroffenen Dimensionen gemeldet |
## 6. Verifikation
```bash
npm test
npm run build
```
Beide müssen grün sein. Zusätzlich ist zu belegen, dass alle bestehenden Profil-,
Import- und Result-DB-Tests ohne inhaltliche Änderung weiter bestehen.
## 7. Nicht Teil dieses Auftrags
- Zerlegung von Kategorie- oder Tag-Werten anderer Profile. Purple Knight,
PingCastle, CIS-CAT und Generic bleiben unangetastet und erhalten keinen
Manifest-Eintrag.
- Ein kuratiertes NIS2-, BSIG- oder KRITIS-Mapping. Das wäre ein eigener,
versionierter Katalog und eine eigene Entscheidung.
- Ein quellenübergreifender Framework-Abgleich zwischen CIS-M365 (Maester) und
den CIS-Windows-Benchmarks (CIS-CAT). Es sind verschiedene Benchmarks; eine
Zusammenführung wäre eine Erfindung.
- Änderungen an der Maester-Erkennung, am Tabellen- oder Statusmapping, an der
`securityScoring`- oder `timeslotComparison`-Deklaration.
## 8. Abschluss
Nach Abschluss ist `docs/REIMPORT_POLICY.md` um eine datierte Klarstellung zu
ergänzen, die festhält: Maester `1.1.0` → `1.2.0`, Mapping `1.1.0` → `1.2.0`,
Grund die Zerlegung der deklarierten Tag-Liste, Reimport erforderlich, betroffene
Dimensionen `mapping` und `presentation`, keine SQL-Migration, keine Änderung an
Erkennung, Status, Outcome, Severity oder IOE. `docs/REPORT_STRATEGY.md` erhält
den Change-Log-Eintrag für den Abschnitt „Framework-Abdeckung" und die
Renderer-Version `1.2.0`. Kein Meilenstein wird als erfüllt markiert, bevor seine
Akzeptanzkriterien verifiziert sind.

View File

@ -15,6 +15,8 @@ A processing-profile version must be added and existing affected imports must be
Presentation-only changes, labels, layout, filtering over already retained fields, and score-formula changes over already retained inputs do not require a source reimport. They must not be disguised as processing changes. Presentation-only changes, labels, layout, filtering over already retained fields, and score-formula changes over already retained inputs do not require a source reimport. They must not be disguised as processing changes.
Clarification 2026-09-22 (Purple Knight for Entra ID): profile 1.4.0 and the summary extractor 1.2.0 recognize `Entra ID` summaries as Purple Knight context, retain the tenant and native Entra posture/counters, and label the scan separately from Purple Knight for Active Directory. Earlier imports stored that summary under the generic profile and therefore contain no canonical Entra observations. They remain unchanged and are marked `Reimport recommended`; the original files are required because scanner source bytes are not retained. Reimport uses the existing auditable replacement path and preserves eligible client-owned review state. No SQL migration is required.
Every scanner profile, including future profiles, declares its current processing contract. Historical contracts remain append-only. A material import change increments the relevant parser, extractor, profile, mapping, normalization, or presentation version and records `reimportRequired` plus the affected dimensions in `PROCESSING_VERSION_MANIFESTS`. Data Hub derives its warning from that persisted contract. Every scanner profile, including future profiles, declares its current processing contract. Historical contracts remain append-only. A material import change increments the relevant parser, extractor, profile, mapping, normalization, or presentation version and records `reimportRequired` plus the affected dimensions in `PROCESSING_VERSION_MANIFESTS`. Data Hub derives its warning from that persisted contract.
Clarification 2026-09-11: Data Hub assesses processing readiness per stored scan run. A generic-only run remains review-required. Generic companion files alongside exactly one recognized scanner profile in the same run do not independently require reimport when all file processing contracts are current or compatible. Every file retains its own profile, provenance and mapping; an inherited display label is not native recognition. A different run cannot supply the missing scanner context. Unknown/incompatible contracts, actual profile updates and unresolved client-review transfers remain actionable. A review-only state does not offer repeated reimport with unchanged processing as a remedy. This corrects status presentation over existing facts and requires no reimport or processing-version change. Clarification 2026-09-11: Data Hub assesses processing readiness per stored scan run. A generic-only run remains review-required. Generic companion files alongside exactly one recognized scanner profile in the same run do not independently require reimport when all file processing contracts are current or compatible. Every file retains its own profile, provenance and mapping; an inherited display label is not native recognition. A different run cannot supply the missing scanner context. Unknown/incompatible contracts, actual profile updates and unresolved client-review transfers remain actionable. A review-only state does not offer repeated reimport with unchanged processing as a remedy. This corrects status presentation over existing facts and requires no reimport or processing-version change.

View File

@ -0,0 +1,348 @@
# Umsetzungsauftrag: Report A (Management) und Report B (Technical)
Adressat: Codex. Dieser Auftrag ist verbindlich und abschließend formuliert.
Wo dieser Auftrag schweigt, gelten `AGENTS.md`, `docs/REPORT_STRATEGY.md`,
`docs/SCORING_MODEL.md` und `.claude/skills/assessor-ui/SKILL.md` unverändert
weiter. Bei einem Widerspruch zwischen diesem Auftrag und einer dieser Quellen:
**anhalten und den Widerspruch melden, nicht eigenmächtig auflösen.**
Plan-Zuordnung: Die Artefakte A–D sind in `docs/REPORT_STRATEGY.md` als
Deliverable ohne zugewiesene Plan-Phase festgehalten. Dieser Auftrag setzt A und
B um und ändert die Strategie inhaltlich nicht.
## 0. Ausgangszustand im Code (verifiziert)
| Baustein | Zustand |
| --- | --- |
| `src/domain/report-projection.ts` | vorhanden, `REPORT_PROJECTION_VERSION = '1.0.0'`, getestet |
| `src/exports/management-overview-report.ts` | vorhanden, `buildManagementOverviewHtml`, Renderer-Version `'1.0.0'`, getestet |
| Produktiver Aufrufer von `buildReportProjection` | **existiert nicht** — nur Tests |
| Produktiver Aufrufer von `buildManagementOverviewHtml` | **existiert nicht** — nur Tests |
| Report B (Technical) | **existiert nicht** |
| Security-Domain-Zuordnung über `UnifiedResultProjection` | **existiert nicht** (Katalog arbeitet auf `FindingRecord`) |
| Framework-Referenzen (MITRE ATT&CK / D3FEND) in der Unified-Projektion | **nicht enthalten** (nur `targetRole === 'category'` wird übernommen) |
Die beiden vorhandenen Bausteine sind korrekt und werden **nicht neu geschrieben**,
sondern additiv erweitert.
## 1. Lieferumfang
Acht Arbeitspakete, in dieser Reihenfolge. Jedes Paket ist einzeln lauffähig,
getestet und wird einzeln committet.
### AP1 — Security-Domain-Zuordnung für `UnifiedResultProjection`
**Datei:** `src/features/dashboard/security-domains.ts` (erweitern, nicht ersetzen)
Neue Funktion:
```ts
export function securityDomainIdForUnifiedResult(result: UnifiedResultProjection): string
```
Bindende Semantik:
- Es gilt **derselbe** `securityDomainCatalog` in **derselben** Reihenfolge.
Kein zweiter Katalog, keine zweite Regex-Menge, keine report-eigene Zuordnung.
- Evidenztext ist: `title`, `description` sowie `categoryLabel` aller Einträge
aus `result.categories`.
- Die explizite GPO-Erkennung geht wie bisher der Regex-Prüfung vor: erfüllt,
wenn `result.sourceKind` einer von `'policy-evaluation' | 'policy-observation'`
ist **oder** ein `categoryLabel` exakt `GPO`/`GPOs` lautet (case-insensitiv).
- Kein Treffer ⇒ `unclassifiedSecurityDomain.id`.
**Akzeptanzkriterium (Pflichttest):** Für einen synthetischen Fall, der sowohl
als `FindingRecord` als auch als `UnifiedResultProjection` gebaut wird, liefern
`securityDomainIdForRecord` und `securityDomainIdForUnifiedResult` **dieselbe**
Domain — für alle acht Katalogeinträge, für den GPO-Sonderfall und für
`unclassified`. Grund: Client-Info-Drilldown und Reports müssen dieselben
Datensatzidentitäten auswählen (`AGENTS.md`, Abschnitt *UI Work filter context*).
### AP2 — Framework-Referenzen in der Unified-Projektion sichtbar machen
**Datei:** `src/domain/unified-result.ts`
Neu:
```ts
export interface UnifiedResultFrameworkReference {
namespace: string; // Tool-ID der Quelle
value: string; // normalizedValue der Mapping-Eintragung, unverändert
sourceFieldKey?: string; // sourceFieldKeys[0], falls vorhanden
sourceLabel?: string; // Original-Spaltenüberschrift, falls vorhanden
}
```
und additiv auf `UnifiedResultProjection`:
```ts
frameworkReferences: UnifiedResultFrameworkReference[];
```
Befüllung analog zu `scannerResultCategories`, aber aus den Mappings mit
`targetRole === 'framework-reference'`. Leere Werte werden verworfen, alle
übrigen **unverändert und unnormalisiert** übernommen.
Bindende Randbedingungen:
- Die Ableitung erfolgt **zur Projektionszeit aus bereits persistierten
`fieldMappings`**. Es wird **keine** neue Spalte, Tabelle oder Migration
angelegt und **kein** Parsing geändert.
- Damit ist dies presentation-only über bereits gespeicherte Fakten:
**kein** neuer Processing-Manifest-Eintrag, **keine** Reimport-Markierung
(`docs/REIMPORT_POLICY.md`).
- Sollte sich beim Umsetzen zeigen, dass eine Persistenzänderung nötig wäre:
**anhalten und melden**, nicht persistieren.
**Akzeptanzkriterium:** Synthetische Occurrence mit zwei
`framework-reference`-Mappings und einem `category`-Mapping ⇒ zwei
`frameworkReferences`, eine `category`, keine Vermischung, Originalwerte identisch.
### AP3 — Korrelationsmodul (Domain, plattformneutral)
**Neue Datei:** `src/domain/report-correlation.ts`
**Konstante:** `export const REPORT_CORRELATION_VERSION = '1.0.0';`
Kein Storage, keine Uhr, kein Netzwerk, keine Präsentationstypen. Drei Ergebnisse:
**(a) Quellenkonvergenz je Security Domain**
```ts
export interface ReportDomainConvergence {
domainId: string;
sources: Array<{ toolId: string; assessed: number; deviations: number }>;
sourceCount: number; // Quellen mit mindestens einer Abweichung
evidence: 'shared-framework-reference' | 'domain-only';
}
```
`evidence` ist `'shared-framework-reference'` **nur dann**, wenn mindestens zwei
Quellen in dieser Domain eine **exakt gleiche, nicht leere**
`frameworkReferences`-Zeichenkette tragen. Andernfalls `'domain-only'`.
**(b) Maßnahmenbündel**
```ts
export interface ReportRemediationBundle {
key: string; // die Framework-Referenz, unverändert
namespace: string;
members: string[]; // resultProjectionIds
toolIds: string[];
domainIds: string[];
}
```
Gebildet über identische `frameworkReferences`-Werte von Abweichungen. Bündel
mit weniger als zwei Mitgliedern entfallen. Sortierung: Mitgliederzahl absteigend,
danach `key` alphabetisch — stabil, damit Ausgaben reproduzierbar sind.
**(c) GPO-Einzelquellen-Maßnahmen**
```ts
export interface ReportSingleSourcePolicyAction {
policyEntryId: string;
backupInstanceId: string;
}
```
Genau die `ReportPolicyEntry` mit `verdict === 'Fail'`, `conflict === false`,
`duplicate === false` und **exakt einem** Eintrag in `backupInstanceIds`.
Bindende Verbote für AP3:
- **Keine** Titel-, Wort-, Fuzzy- oder Ähnlichkeitsheuristik über Quellen hinweg.
- **Kein** quellenübergreifender Score, Rang oder normalisierter Schweregrad.
- **Keine** Aussage „gleicher Befund" — die Aussage lautet ausschließlich
„dieselbe Domain" bzw. „dieselbe Framework-Referenz".
- **Keine** Aufwands-, Kosten- oder Quick-Win-Schätzung. Ein Bündel trägt eine
Mitgliederzahl, keine Bewertung.
### AP4 — Projektion erweitern
**Datei:** `src/domain/report-projection.ts`
**Version:** `REPORT_PROJECTION_VERSION` von `'1.0.0'` auf `'1.1.0'`.
Additiv auf `ReportProjection`:
```ts
convergence: ReportDomainConvergence[];
bundles: ReportRemediationBundle[];
singleSourcePolicyActions: ReportSingleSourcePolicyAction[];
```
Bestehende Felder, Typen und Semantiken bleiben unverändert. Die bestehenden
Zusicherungen in `tests/domain/report-projection.test.ts` müssen ohne Änderung
grün bleiben; neue Tests kommen hinzu.
### AP5 — Report-Eingabe zusammenstellen
**Neue Datei:** `src/features/reports/report-input-assembler.ts`
```ts
export async function assembleReportProjectionInput(
repository: AssessorRepository,
scope: { projectId: string; assessmentId?: string; generatedAt: string }
): Promise<ReportProjectionInput>
```
Quellen, ausschließlich:
- Ergebnisse: `repository.queryUnifiedResults` über den Scope.
- `domainOf`: `securityDomainIdForUnifiedResult` aus AP1.
- `priorityBases`: `reportPriorityBases(...)` über die registrierten Profile.
- `scoreSources` / `overallScore`: die vorhandene Scoring-Schicht
(`src/domain/security-scoring.ts`,
`src/features/dashboard/dashboard-security-score.ts`).
**Keine eigene Score-Arithmetik.**
- `environment`: normalisierte Fakten aus dem Environment-Snapshot. Fehlende
Fakten bleiben **absent** und werden nie zu `0`.
- `policyEntries` / `policyScopeLimited`: aus der Evaluations- und
Aggregationsschicht mit dem **autoritativen, hochgerollten** Verdict.
**Kein** Verdict wird hier neu abgeleitet.
- `reviews`, `diagnostics`, `provenance`: aus den vorhandenen Repository-Pfaden.
- `delta`: nur, wenn ein **gespeicherter** Timeslot-Vergleich existiert. Es wird
**keine** Referenzlauf-Auswahl und **keine** Paarung im Export gebildet.
Die Zusammenstellung läuft **nur beim Export**, nie beim Laden eines Workspace.
### AP6 — Management-Renderer erweitern
**Datei:** `src/exports/management-overview-report.ts`
**Version:** `MANAGEMENT_OVERVIEW_RENDERER_VERSION` auf `'1.1.0'`.
Bestehende Abschnitte bleiben unverändert. Zwei neue Abschnitte:
1. **„Konvergenz der Quellen"** — direkt nach `Security Domains`. Je Domain:
Anzahl unabhängiger Quellen mit Abweichungen, die Quellen namentlich, und eine
sichtbare Unterscheidung zwischen belegter Konvergenz
(`shared-framework-reference`) und thematischer Nähe (`domain-only`). Letztere
trägt im Fließtext ausdrücklich den Hinweis auf **Validierungsbedarf**.
2. **„Maßnahmenbündel"** — direkt nach `Priority Findings`. Die Bündel aus AP3(b)
mit Referenz und Mitgliederzahl sowie die Anzahl der Einzelquellen-GPO-
Maßnahmen aus AP3(c). **Keine** Aufwands- oder Nutzenaussage.
Der Abschnitt „Abdeckung und Lücken" wird **vor** „Priority Findings" gezogen und
zeigt zusätzlich je Quelle den Anteil `not_run` an der Gesamtzahl. Begründung:
Die Aussagegrenze muss vor der Priorisierung stehen.
Unverändert bindend: keine Registry-Keys, SIDs, Evidenzpfade, Parser- oder
Manifest-Versionen in Report A.
### AP7 — Report B (Technical)
**Neue Datei:** `src/exports/technical-report.ts`
**Konstante:** `export const TECHNICAL_REPORT_RENDERER_VERSION = '1.0.0';`
**Signatur:** `export function buildTechnicalReportHtml(projection: ReportProjection): string`
Abschnitte in dieser Reihenfolge, gemäß `docs/REPORT_STRATEGY.md` Report B:
1. Scope und Provenance — Importe, Processing-, Analyzer- und Manifest-Versionen,
Reimport-Status, Aggregations- und Evaluations-Run-IDs.
2. Ergebnisinventar — Totals je Status, Severity, Domain und Tool, inklusive der
score-neutralen Klassen; Passed erscheint als Zahl.
3. Findings im Detail — gruppiert je Domain, innerhalb der Gruppe nach Severity.
4. GPO-Policy-Register — alle sieben Verdicts vollständig; die score-neutralen
sind **am Ort der Anzeige** als score-neutral gekennzeichnet.
5. Konvergenz-Nachweis — die Paare aus AP3(a) mit ihrer Begründung, damit die
Managementaussage aus AP6 nachprüfbar ist.
6. Scope-Grenzen — ausdrücklich: kein RSoP, keine Aussage über wirksame
Endpoint-Konfiguration, GPO-Aussagen gelten für das importierte Backup-Set,
`not_run` ist kein Pass.
7. Diagnostik — jede Beobachtung **einmal**, mit ihrem autoritativen Resolver-Grund.
8. Anhang — vollständiger Ergebnisindex.
### AP8 — Auslösung in der Anwendung
Zwei Aktionen in der bestehenden Oberfläche, die den Scope des aktuellen Projekts
bzw. Assessments verwenden und über `src/exports/download-adapter.ts` bzw.
`src/exports/text-download.ts` ausliefern. Keine neue Navigationsebene, keine neue
Shell, keine neuen Farb- oder Geometrie-Tokens. Es gilt
`.claude/skills/assessor-ui/SKILL.md` und der 20px-Panel-Inset.
## 2. Gestaltungsvorgaben für beide Dateien
- Sprache: Deutsch. Etablierte englische Fachbegriffe bleiben englisch
(`Deviation`, `Passed`, `Not configured`, `No baseline rule`, `Not comparable`,
`Conflicts`, `Duplicates`, `Finding`, `Baseline`, `Score`, `Pass Rate`,
Security-Domain-Namen, `Group Policy`, `Registry`, `Domain Controller`).
- Zahlen und Daten in deutscher Formatierung. Unbekanntes ist ein Geviertstrich,
niemals eine `0`.
- Getrennte Dateien. Report A enthält keine technischen Identifikatoren.
- Vollständig eigenständig: alle Styles inline, kein CDN, keine externe Font,
kein Skript, kein Bild, kein Netzwerkzugriff beim Öffnen. Druckbar nach PDF
ohne Layoutverlust.
- Typografie-Rollen, Donut-Geometrie, Tile- und Segment-Grammatik,
Statusfarbregeln und Panel-Inset wie in der Anwendung. Statusfarbe nur in
Akzentlinie, Marker, Arc oder Score-Node — nie als Flächenfüllung.
## 3. Harte Verbote
Nicht verhandelbar, auch dann nicht, wenn sie eine Lücke schließen würden:
1. Kein quellenübergreifender, normalisierter Schweregrad, Rang oder Risiko-Score.
2. Keine Aussage über wirksame Endpoint-Konfiguration aus einem Backup-Vergleich.
3. Kein GPO-Verdict aus Vergleichsprosa, Historie, Baseline-Text, Guidance oder
Evidenz — ausschließlich das autoritative hochgerollte Verdict plus die
expliziten Conflict- und Duplicate-Flags.
4. Ein selbst gemeldeter Review-Status wird nie zu einem gelösten Finding.
5. Kein Delta ohne gespeicherten Vergleich; keine Referenzlauf-Auswahl im Export.
6. Keine Score-Arithmetik, kein quellenspezifisches Parsing und kein
Datenbankzugriff in einem Renderer.
7. `Not configured`, `No baseline rule` und `Not comparable` bleiben score-neutral
und erscheinen nie in den Priority Findings.
8. Keine Titel- oder Ähnlichkeitsheuristik als Beleg für „gleicher Befund".
9. Keine echten Kunden-, Mandanten- oder Scan-Inhalte in Tests, Fixtures, Logs
oder Commit-Nachrichten. Ausschließlich synthetische Fixtures.
## 4. Tests (Pflicht, synthetisch)
| Paket | Testdatei | Muss nachweisen |
| --- | --- | --- |
| AP1 | `tests/features/security-domain-unified.test.ts` | Gleichstand beider Klassifizierer über alle acht Domains, GPO-Sonderfall, `unclassified` |
| AP2 | `tests/domain/unified-result-framework-references.test.ts` | Trennung `category` / `framework-reference`, Originalwerte unverändert, leere Werte verworfen |
| AP3 | `tests/domain/report-correlation.test.ts` | `shared-framework-reference` nur bei exakter Übereinstimmung über mindestens zwei Quellen; Bündel unter zwei Mitgliedern entfallen; stabile Sortierung; GPO-Einzelquelle schließt Conflict, Duplicate und Mehrfach-Backups aus |
| AP4 | Erweiterung `tests/domain/report-projection.test.ts` | Neue Felder vorhanden, bestehende Zusicherungen unverändert grün |
| AP5 | `tests/features/report-input-assembler.test.ts` | Kein Verdict wird neu abgeleitet; fehlende Fakten bleiben absent; `delta` bleibt ohne gespeicherten Vergleich `undefined`; Zusammenstellung nur beim Export |
| AP6 | Erweiterung `tests/exports/management-overview-report.test.ts` | Beide neuen Abschnitte vorhanden; `domain-only` trägt den Validierungshinweis; keine Registry-Keys, SIDs, Pfade oder Versionen im Output; Abdeckung steht vor Priority Findings |
| AP7 | `tests/exports/technical-report.test.ts` | Alle acht Abschnitte; score-neutrale Verdicts als solche gekennzeichnet; Diagnostik je Beobachtung genau einmal; Scope-Grenzen-Abschnitt vorhanden |
| AP8 | Verhaltenstest der Auslösung | Export läuft nur auf Klick, nicht beim Laden des Workspace |
Zusätzlich für Report A und B je ein Selbstgenügsamkeits-Test: der erzeugte
HTML-String enthält kein `<script`, kein `@import`, keine externe Font-URL und
keine `http`- oder `https`-Ressourcenreferenz in `src` oder `href`.
## 5. Verifikation
```bash
npm test
npm run build
```
Beide müssen grün sein. Zusätzlich ist zu belegen, dass die bestehenden Tests zu
`report-projection` und `management-overview-report` ohne inhaltliche Änderung
weiter bestehen.
## 6. Nicht Teil dieses Auftrags
- **Artefakt C (Arbeitsgrundlage)** und **Artefakt D (Delta Report)**. D ist
mangels gespeicherter Vergleiche ohnehin nicht lieferbar.
- **Framework-Abdeckung aus Maester-Tags (CISA SCuBA, CIS M365, Lizenzstufen).**
Grund: Die Tag-Liste ist heute als **eine** opake Category-Zeichenkette
gespeichert, teils doppelt in zwei Serialisierungen. Eine belastbare Tag-Sicht
erfordert eine Profil- und Importer-Änderung mit Reimport-Pflicht nach
`docs/REIMPORT_POLICY.md` und ist als eigener Auftrag zu beschließen. Bis dahin
wird die Zeichenkette **nicht** geraten, gesplittet oder interpretiert.
- **Asset- und Angriffspfad-Korrelation.** `assessor_canonical_assets` und
`assessor_asset_edges` sind leer, `affectedAsset` ist nur bei einer Quelle und
dort nahezu konstant belegt. Ein Pfad-Graph wäre eine Erfindung.
- Änderungen an Data Hub, Work, Pathfinder oder Client Info, die über die
Auslösung aus AP8 hinausgehen.
## 7. Abschluss
Nach Abschluss aller Pakete ist `docs/REPORT_STRATEGY.md` im selben Change um
einen Change-Log-Eintrag zu ergänzen, der festhält: Report A auf Renderer-Version
`1.1.0` erweitert, Report B in Version `1.0.0` implementiert, Projektion auf
`1.1.0`, Korrelationsmodul in `1.0.0` eingeführt, Reimport nicht erforderlich.
Kein Meilenstein wird als erfüllt markiert, bevor seine Akzeptanzkriterien
verifiziert sind.

View File

@ -410,10 +410,56 @@ Policy-Evaluation-Entitäten und werden nur im Read-Modell vereinheitlicht.
### Datenschutzgrenze der Result DB ### Datenschutzgrenze der Result DB
Die Result DB ist kein Rohdaten- oder Kundenkontextspeicher. Kanonische Findings Die Result DB ist eine fail-closed, LLM-faehige Offenlegungsprojektion und kein
bleiben mit ihren allgemeinen Regeltexten, Bewertungen und Frameworkreferenzen Rohdaten- oder Kundenkontextspeicher. Seit Disclosure Policy 2.1.0 duerfen
sichtbar; verknuepfte Results, originale Tabellenfelder und vollstaendige regulaere kanonische Findings und GPO-Vergleiche keinen personen- oder
Scannerkontexte bleiben ausserhalb dieser Projektion erhalten. kundenidentifizierenden Inhalt transportieren. Bei den explizit bekannten
Scannerprofilen CIS-CAT, Maester, PingCastle und Purple Knight bleiben jedoch
regelbezogene Titel, Beschreibungen und Empfehlungen erhalten, damit die
Result DB fachlich und fuer ein LLM nutzbar bleibt. Vor der Ausgabe ersetzt die
Projektion exakte private Begleitwerte und feste Identifierklassen durch
typisierte Platzhalter und wandelt HTML-Regelprosa in Klartext um. Assets,
Personen-/Reviewfelder, Tenant-/Host-/GPO-/Backupidentitaeten, Locators,
Quellzeitpunkte und interne Artefaktprovenienz bleiben ausschliesslich im
internen Analysepfad. Unbekannte Werkzeuge und generische Quellen fallen
weiterhin auf kontrollierte Bezeichnungen ohne Quellprosa zurueck.
Disclosure Policy 2.5.0 erweitert die freigegebenen PingCastle-Rule-IDs auf den
oeffentlich dokumentierten Regelkatalog. Die Validierung ist
gross-/kleinschreibungsunabhaengig, die Ausgabe behaelt die native Schreibweise;
unbekannte IDs bleiben aus der LLM-faehigen Result DB ausgeschlossen.
Disclosure Policy 2.5.0 zieht fuer GPO-Vergleiche die verbindliche Grenze
zwischen technischer Abweichung und Quellenidentitaet. Titel, technische
Setting-ID, Policy-Pfad, Value Name, Operation, Registry-Typ, konfigurierter
Wert, erwarteter Wert, Comparator-Relation, Status, oeffentliche
Baselinebezeichnung/-version, oeffentliche Beschreibung/Empfehlung, belegte
Diagnosen, Verarbeitungsstaende und die Aussagegrenze des Backupvergleichs
duerfen vollstaendig erscheinen. Kunden-/Projektname, Domainlabel,
kundenspezifischer GPO-Name, GPO-/Backup-GUID, Datei-/Sharepfad, interne
Projekt-/Assessment-/Run-/Import-/Artefaktidentitaet, Source Locator,
Zeitpunkte und Review-/Risikoakzeptanzdaten bleiben ausgeschlossen.
`policyIdentityKey` und interne Baseline-IDs werden nicht ausgegeben; die
technische Setting-ID steht ausschliesslich als kontrollierte `Source Rule ID`.
Automatisch erzeugte `gpo-policy-identity-*`-Hashes sind niemals oeffentliche
Regelidentitaeten.
Wohlbekannte oeffentliche Principals wie `S-1-5-18`, `S-1-5-32-544` und
`BUILTIN\\Administrators` bleiben technisch lesbar. Domain-SIDs verlieren ihre
eindeutigen Domainkomponenten (`S-1-5-21-[DOMAIN]-<RID>`), bekannte
Domainrollen werden als `[DOMAIN]\\<Rolle>` dargestellt und sonstige konkrete
Benutzer/Gruppen erhalten stabile, nur innerhalb des einzelnen Resultats
gueltige Platzhalter `[DOMAIN_PRINCIPAL_n]`. Dieselbe Reduktion gilt rekursiv
fuer Baselinevarianten. Die Originalzuordnung bleibt ausschliesslich in Work
und GPO Analysis erhalten.
Die fachlichen Bewertungsachsen bleiben erhalten: normalisierte Severity und
Outcome, Result-Typ, Abweichung/IOE, kontrollierte GPO-Klassifikation,
Comparator-Relation sowie nicht identifizierende Aggregate und technische
Versionsmetadaten. Damit bleibt die Sicherheitsbewertung fuer ein LLM nutzbar,
ohne Kunden- oder konkrete GPO-Quellenidentitaet weiterzugeben. Die Aussage bezieht sich auf
Identifizierbarkeit; ein Finding beschreibt weiterhin den anonymisierten
Sicherheitszustand des aktiv ausgewaehlten Scopes.
Ergaenzende Scannerbeobachtungen durchlaufen vor der Unified-Result-Projektion Ergaenzende Scannerbeobachtungen durchlaufen vor der Unified-Result-Projektion
die versionierte, fail-closed Disclosure Policy die versionierte, fail-closed Disclosure Policy
@ -432,9 +478,13 @@ im expliziten `internal-analysis`-Lesemodus fuer Work und GPO Analysis erhalten;
versioniert evaluierte Soll-/Ist-Vergleiche erreichen die normale Result DB als versioniert evaluierte Soll-/Ist-Vergleiche erreichen die normale Result DB als
`policy_deviation`. Ohne freigegebene Risikoregel bleibt die Severity `unknown`; `policy_deviation`. Ohne freigegebene Risikoregel bleibt die Severity `unknown`;
die Finding Factory darf nur belegte Ergebnisse anreichern und erzeugt keine die Finding Factory darf nur belegte Ergebnisse anreichern und erzeugt keine
doppelte Result-Zeile. Der JSONL-Export verwendet Schema 1.1.0, verlangt fuer jede doppelte Result-Zeile. Der JSONL-Export verwendet Schema 2.0.0 und Disclosure
Environment-Zeile eine Disclosure-Klassifikation und entfernt die gesperrten Policy 2.5.0. Er wendet die allgemeine Projektion nochmals an, verlangt fuer
Felder nochmals unmittelbar vor der Serialisierung. jede Environment-Zeile weiterhin eine Disclosure-Klassifikation und entfernt
zusaetzlich interne Result-, Projekt-, Assessment-, Run-, Snapshot-, Evaluation-
und Artefaktidentitaeten sowie Zeitpunkte. Manifest-Scope und Filter werden nur
als Boolesche beziehungsweise Mengenangaben ausgegeben; Suchtext, Assetfilter
und konkrete Scope-IDs werden nie serialisiert.
### Asset-/Kantenschicht: Struktur ohne Producer ### Asset-/Kantenschicht: Struktur ohne Producer

View File

@ -30,7 +30,8 @@ pass rate. Never combine the native score and the pass rate of the same scan.
| Source | Native contribution | Fallback | | Source | Native contribution | Fallback |
| --- | --- | --- | | --- | --- | --- |
| Purple Knight AD | Native AD posture, 0–100 | Pass rate | | Purple Knight for Active Directory | Native AD posture, 0–100 | Pass rate |
| Purple Knight for Entra ID | Native Entra posture, 0–100 | Pass rate |
| CIS-CAT | Native benchmark score, 0–100 | Pass rate | | CIS-CAT | Native benchmark score, 0–100 | Pass rate |
| PingCastle Healthcheck | `100 - native risk`, bounded 0–100 | No pass rate from findings-only reports | | PingCastle Healthcheck | `100 - native risk`, bounded 0–100 | No pass rate from findings-only reports |
| Maester | No native numeric score assumed | Pass rate | | Maester | No native numeric score assumed | Pass rate |
@ -38,10 +39,12 @@ pass rate. Never combine the native score and the pass rate of the same scan.
| Additional registered scanners | Native overall score declared by their profile | Declared result-population fallback | | Additional registered scanners | Native overall score declared by their profile | Declared result-population fallback |
| Unrecognized tabular sources | No native score inferred | Provisional pass rate only with evidence of passed results | | Unrecognized tabular sources | No native score inferred | Provisional pass rate only with evidence of passed results |
The Purple Knight AD score must not stand for a hybrid report containing other Purple Knight AD and Purple Knight Entra are separate scan scopes. Their native
native posture scopes. Such reports use the disclosed fallback until supported posture values are bound respectively to the imported forest or Entra tenant
scope-specific contributions are available. A missing native score on an old and must never be substituted for one another. A mixed report with conflicting
import requires the relevant context to be imported; no score is fabricated. native scopes remains unavailable rather than being collapsed into one score.
A missing native score on an old import requires the relevant summary context
to be reimported; no score or tenant is fabricated.
Null, blank, boolean, nonnumeric, out-of-range and conflicting native values Null, blank, boolean, nonnumeric, out-of-range and conflicting native values
remain unavailable. A valid zero is a real score. Invalid native data and source remain unavailable. A valid zero is a real score. Invalid native data and source
@ -187,3 +190,10 @@ scan run and processing contract. The regression with 65 native posture,
96 passed and 19 deviations yields 65 as the dashboard contribution while 96 passed and 19 deviations yields 65 as the dashboard contribution while
retaining all 19 deviations. `npm test`: 1,199 passed, eight optional retaining all 19 deviations. `npm test`: 1,199 passed, eight optional
PostgreSQL tests skipped; security/design checks and both builds passed. PostgreSQL tests skipped; security/design checks and both builds passed.
Purple Knight Entra follow-up 2026-09-22: profile 1.4.0 recognizes the native
`Entra ID` assessment summary, persists its tenant, posture, counters and
category scores, and labels the scan independently from Purple Knight for
Active Directory. The same native-first rule applies within the Entra scope;
the tenant name is its target identity. Historical 1.3.0 imports do not gain
these facts at read time and require reimport of the original files.

4
package-lock.json generated
View File

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

View File

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

View File

@ -66,6 +66,7 @@ import {
type UnifiedResultQuery, type UnifiedResultQuery,
type UnifiedResultQueryResult type UnifiedResultQueryResult
} from '../../src/domain/unified-result'; } from '../../src/domain/unified-result';
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
import { createPolicyConsistencyUnifiedResults } from '../../src/evaluation/policy-consistency-results'; import { createPolicyConsistencyUnifiedResults } from '../../src/evaluation/policy-consistency-results';
import { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping'; import { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping';
import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../../src/domain/ingestion-persistence'; import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../../src/domain/ingestion-persistence';
@ -1464,19 +1465,27 @@ export class PostgresAssessorRepository implements AssessorRepository {
} }
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> { private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
// Public tool identities are rebuilt by the Result DB disclosure policy.
// Do not prefilter on the untrusted source value before that mapping; the
// shared in-memory filter below applies the public value consistently.
const sourceQuery = query.audience === 'internal-analysis' ? query : { ...query, sourceTool: undefined };
const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([ const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([
this.unifiedScannerResults(query), this.unifiedScannerResults(sourceQuery),
this.unifiedEnvironmentResults(query), this.unifiedEnvironmentResults(sourceQuery),
this.unifiedPolicyObservationResults(query), this.unifiedPolicyObservationResults(sourceQuery),
this.unifiedPolicyConsistencyResults(query), this.unifiedPolicyConsistencyResults(sourceQuery),
this.unifiedPolicyEvaluationComparisonResults(query), this.unifiedPolicyEvaluationComparisonResults(sourceQuery),
this.unifiedPolicyResults(query) this.unifiedPolicyResults(sourceQuery)
]); ]);
return [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; const results = [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
return query.audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection);
} }
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> { async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
return queryUnifiedResultItems(await this.allUnifiedResults(query), query); const result = queryUnifiedResultItems(await this.allUnifiedResults(query), query);
return query.audience === 'internal-analysis'
? result
: { ...result, items: result.items.map(removeResultDbScopeIdentifiers) };
} }
async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise<UnifiedResultFacetResult> { async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise<UnifiedResultFacetResult> {
@ -1503,7 +1512,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
: resultProjectionId.startsWith(environmentPrefix) : resultProjectionId.startsWith(environmentPrefix)
? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length)) ? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length))
: []; : [];
return items.find(item => item.resultProjectionId === resultProjectionId) ?? null; const item = items.find(item => item.resultProjectionId === resultProjectionId);
return item ? removeResultDbScopeIdentifiers(discloseResultDbProjection(item)) : null;
} }
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> { async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {

View File

@ -0,0 +1,152 @@
import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
const MIN_WIDTH = 520;
const MIN_HEIGHT = 280;
const VIEWPORT_MARGIN = 8;
const FALLBACK_WORKSPACE_TOP = 44;
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const RESIZE_DIRECTIONS: ResizeDirection[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
interface FloatingDataWindowProps {
title: string;
children: ReactNode;
onClose(): void;
}
function WindowIcon({ maximized }: { maximized: boolean }) {
return <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2">
{maximized
? <><rect x="5" y="7" width="12" height="12" /><path d="M8 7V4h12v12h-3" /></>
: <rect x="4" y="4" width="16" height="16" />}
</svg>;
}
export function FloatingDataWindow({ title, children, onClose }: FloatingDataWindowProps) {
const titleId = useId();
const windowRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; left: number; top: number } | null>(null);
const resizeRef = useRef<{ pointerId: number; direction: ResizeDirection; startX: number; startY: number; left: number; top: number; width: number; height: number } | null>(null);
const [maximized, setMaximized] = useState(false);
const [position, setPosition] = useState(() => ({
left: Math.max(VIEWPORT_MARGIN, (window.innerWidth - Math.min(1100, window.innerWidth * 0.78)) / 2),
top: Math.max(FALLBACK_WORKSPACE_TOP + VIEWPORT_MARGIN, (window.innerHeight - Math.min(720, window.innerHeight * 0.76)) / 2)
}));
const [size, setSize] = useState(() => ({
width: Math.min(1100, window.innerWidth - VIEWPORT_MARGIN * 2),
height: Math.min(720, window.innerHeight - FALLBACK_WORKSPACE_TOP - VIEWPORT_MARGIN * 2)
}));
const workspaceTop = () => document.querySelector<HTMLElement>('.topbar')?.getBoundingClientRect().bottom ?? FALLBACK_WORKSPACE_TOP;
const clampPosition = (left: number, top: number, width = size.width, height = size.height) => ({
left: Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN)),
top: Math.max(workspaceTop() + VIEWPORT_MARGIN, Math.min(top, window.innerHeight - height - VIEWPORT_MARGIN))
});
useEffect(() => {
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onClose();
};
const onResize = () => {
setSize(current => ({
width: Math.min(current.width, window.innerWidth - VIEWPORT_MARGIN * 2),
height: Math.min(current.height, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2)
}));
setPosition(current => clampPosition(current.left, current.top));
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
};
}, [onClose]);
const startDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (maximized || (event.target as HTMLElement).closest('button')) return;
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, ...position };
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const moveDrag = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition(clampPosition(drag.left + event.clientX - drag.startX, drag.top + event.clientY - drag.startY));
};
const stopDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const startResize = (direction: ResizeDirection) => (event: ReactPointerEvent<HTMLElement>) => {
resizeRef.current = {
pointerId: event.pointerId, direction, startX: event.clientX, startY: event.clientY,
left: position.left, top: position.top, width: size.width, height: size.height
};
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const moveResize = (event: ReactPointerEvent<HTMLElement>) => {
const resize = resizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) return;
const dx = event.clientX - resize.startX;
const dy = event.clientY - resize.startY;
let { left, top, width, height } = resize;
if (resize.direction.includes('e')) width = Math.max(MIN_WIDTH, Math.min(resize.width + dx, window.innerWidth - resize.left - VIEWPORT_MARGIN));
if (resize.direction.includes('s')) height = Math.max(MIN_HEIGHT, Math.min(resize.height + dy, window.innerHeight - resize.top - VIEWPORT_MARGIN));
if (resize.direction.includes('w')) {
width = Math.max(MIN_WIDTH, Math.min(resize.width - dx, resize.left + resize.width - VIEWPORT_MARGIN));
left = resize.left + resize.width - width;
}
if (resize.direction.includes('n')) {
height = Math.max(MIN_HEIGHT, Math.min(resize.height - dy, resize.top + resize.height - workspaceTop() - VIEWPORT_MARGIN));
top = resize.top + resize.height - height;
}
setPosition({ left, top });
setSize({ width, height });
};
const stopResize = (event: ReactPointerEvent<HTMLElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return;
resizeRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const top = workspaceTop() + VIEWPORT_MARGIN;
const style = maximized
? { left: VIEWPORT_MARGIN, top, width: window.innerWidth - VIEWPORT_MARGIN * 2, height: window.innerHeight - top - VIEWPORT_MARGIN }
: { ...position, ...size };
return createPortal(<section
ref={windowRef}
className={`floating-data-window${maximized ? ' is-maximized' : ''}`}
style={style as CSSProperties}
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
>
<header className="floating-data-window-head"
onDoubleClick={() => setMaximized(value => !value)}
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={stopDrag} onPointerCancel={stopDrag}>
<h2 id={titleId}>{title}</h2>
<div className="floating-data-window-actions">
<button type="button" onClick={() => setMaximized(value => !value)}
title={maximized ? 'Restore window' : 'Maximize window'} aria-label={maximized ? 'Restore Operating systems window' : 'Maximize Operating systems window'}>
<WindowIcon maximized={maximized} />
</button>
<button ref={closeRef} type="button" onClick={onClose} title="Close" aria-label="Close Operating systems window">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
</header>
<div className="floating-data-window-body client-command-center">{children}</div>
{!maximized && RESIZE_DIRECTIONS.map(direction => <span key={direction}
className={`results-window-resize results-window-resize-${direction}`}
onPointerDown={startResize(direction)} onPointerMove={moveResize} onPointerUp={stopResize} onPointerCancel={stopResize} />)}
</section>, document.body);
}

View File

@ -30,7 +30,8 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
{ profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, { profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, { profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false }, { profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'], current: true }, { profileId: 'purple-knight', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.4.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'scoring'], current: true },
{ 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.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.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.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 },

View File

@ -0,0 +1,370 @@
import catalog from '../profiles/public-search/catalog.json';
import type { JsonValue } from './canonical-model';
import type { PolicyBaselineVariantAssessment } from './policy-evaluation';
import type { UnifiedResultCategory, UnifiedResultProjection } from './unified-result';
export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '2.5.0';
const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z';
const toolCatalog: Record<string, { id: string; label: string }> = {
'cis': { id: 'cis-cat', label: 'CIS-CAT' },
'cis-cat': { id: 'cis-cat', label: 'CIS-CAT' },
'maester': { id: 'maester', label: 'Maester' },
'pingcastle': { id: 'pingcastle', label: 'PingCastle' },
'purple-knight': { id: 'purple-knight', label: 'Purple Knight' },
'purple-knight-ad': { id: 'purple-knight-ad', label: 'Purple Knight for Active Directory' },
'purple-knight-entra': { id: 'purple-knight-entra', label: 'Purple Knight for Entra ID' },
'purple-knight-combined': { id: 'purple-knight-combined', label: 'Purple Knight for Active Directory and Entra ID' },
'purpleknight': { id: 'purple-knight', label: 'Purple Knight' },
'gpos': { id: 'GPOs', label: 'Group Policy' },
'group-policy': { id: 'GPOs', label: 'Group Policy' }
};
function safeTool(value?: string): { id: string; label: string } {
return toolCatalog[value?.trim().toLocaleLowerCase('en-US') ?? '']
?? { id: 'generic', label: 'Security assessment' };
}
function disclosureTool(item: UnifiedResultProjection): { id: string; label: string } {
if (safeTool(item.sourceTool).id !== 'purple-knight') return safeTool(item.sourceTool);
if (item.sourceToolScope === 'active-directory') return safeTool('purple-knight-ad');
if (item.sourceToolScope === 'entra-id') return safeTool('purple-knight-entra');
if (item.sourceToolScope === 'combined') return safeTool('purple-knight-combined');
return safeTool(item.sourceTool);
}
function exactCatalogId(values: readonly string[], value?: string): string | undefined {
const folded = value?.trim().toLocaleLowerCase('en-US');
return folded && values.some(candidate => candidate.toLocaleLowerCase('en-US') === folded)
? value!.trim() : undefined;
}
function safePublicRuleId(item: UnifiedResultProjection): string | undefined {
const tool = disclosureTool(item).id;
const scannerFamily = tool.replace(/-(?:ad|entra|combined)$/, '');
if (tool === 'GPOs' && item.sourceKind === 'policy-evaluation') {
const id = item.sourceRuleId?.trim();
return id && !/^gpo-policy-identity-[0-9a-f]+$/i.test(id) ? id : undefined;
}
if (scannerFamily === 'maester') return exactCatalogId(catalog.maesterIds, item.sourceRuleId);
if (scannerFamily === 'pingcastle') return exactCatalogId(catalog.pingcastleIds, item.sourceRuleId);
if (scannerFamily === 'purple-knight') {
const id = item.sourceRuleId?.trim();
return id && /^SI\d{3,12}$/i.test(id) ? id : undefined;
}
if (scannerFamily !== 'cis-cat') return undefined;
const match = item.sourceRuleId?.match(/(?:^|[^0-9])([0-9]+(?:\.[0-9]+){1,5})(?:$|[^0-9])/);
return match?.[1];
}
const publicDomainNames = new Set([
'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org'
]);
const publicAccountPrefixes = /^(?:BUILTIN|NT AUTHORITY)\\/i;
const publicDomainRoles = new Set([
'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers',
'cert publishers', 'schema admins', 'enterprise admins', 'group policy creator owners',
'read-only domain controllers', 'cloneable domain controllers', 'protected users',
'key admins', 'enterprise key admins', 'ras and ias servers'
]);
class PolicyValueRedactor {
private readonly principals = new Map<string, string>();
private domainToken(_identity: string): string {
return '[DOMAIN]';
}
private principalToken(identity: string): string {
const key = identity.toLocaleLowerCase('en-US');
const existing = this.principals.get(key);
if (existing) return existing;
const token = `[DOMAIN_PRINCIPAL_${this.principals.size + 1}]`;
this.principals.set(key, token);
return token;
}
text(value: string, redactAccountNames = true): string {
let redacted = value;
redacted = redacted.replace(/(\*?)S-1-5-21-(\d+)-(\d+)-(\d+)-(\d+(?:-\d+)*)/giu,
(_match, marker: string, first: string, second: string, third: string, rid: string) =>
`${marker}S-1-5-21-${this.domainToken(`${first}-${second}-${third}`)}-${rid}`);
redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu,
match => this.principalToken(match));
if (redactAccountNames) {
redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, match => {
if (publicAccountPrefixes.test(match)) return match;
const separator = match.indexOf('\\');
const domain = match.slice(0, separator);
const principal = match.slice(separator + 1);
return publicDomainRoles.has(principal.toLocaleLowerCase('en-US'))
? `${this.domainToken(domain)}\\${principal}`
: this.principalToken(match);
});
}
redacted = redacted
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[PATH]')
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[PATH]')
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[ID]')
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match =>
publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match));
return redacted;
}
json(value: JsonValue | undefined, propertyName?: string): JsonValue | undefined {
if (value === undefined) return undefined;
if (typeof value === 'string') {
const structural = /^(?:key|valueName|operation|kind|state|typeCode)$/i.test(propertyName ?? '');
return this.text(value, !structural);
}
if (value === null || typeof value === 'number' || typeof value === 'boolean') return value;
if (Array.isArray(value)) return value.map(item => this.json(item, propertyName) as JsonValue);
const blocked = new Set([
'projectid', 'projectname', 'assessmentid', 'runid', 'scanrunid', 'artifactid', 'importartifactid',
'gpoid', 'gpoguid', 'gponame', 'backupid', 'backupguid', 'backupname', 'filename', 'filepath',
'sourcepath', 'sourcelocator'
]);
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !blocked.has(key.replace(/[^a-z0-9]/gi, '').toLocaleLowerCase('en-US')))
.map(([key, child]) => [key, this.json(child, key) as JsonValue]));
}
}
function safePolicyNarrative(item: UnifiedResultProjection, redactor: PolicyValueRedactor, value?: string): string | undefined {
if (!value?.trim()) return undefined;
let result = value.trim();
for (const literal of privateLiterals(item)) result = result.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]');
result = redactor.text(result).replace(/(?:\[redacted\]\s*){2,}/giu, '[redacted] ').trim();
return result || undefined;
}
function safeBaselineLabel(item: UnifiedResultProjection, redactor: PolicyValueRedactor): string {
const label = item.policyBaselineLabel?.trim();
if (!label) return 'Baseline recommendation';
const publicPart = /\b(?:Microsoft|Windows|CIS|ANSSI|DISA|NCSC)\b.*$/iu.exec(label)?.[0];
return redactor.text(publicPart ?? 'Baseline recommendation');
}
function safeBaselineVariantAssessment(
assessment: PolicyBaselineVariantAssessment | undefined,
redactor: PolicyValueRedactor
): PolicyBaselineVariantAssessment | undefined {
if (!assessment) return undefined;
return {
version: 1,
scope: 'backup-configuration',
decision: assessment.decision,
actualValues: redactor.json(assessment.actualValues as unknown as JsonValue) as unknown as PolicyBaselineVariantAssessment['actualValues'],
variants: assessment.variants.map((variant, index) => ({
settingId: `baseline-variant-${index + 1}`,
baselineValues: redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues,
comparator: {
...variant.comparator,
actual: redactor.json(variant.comparator.actual as unknown as JsonValue) as typeof variant.comparator.actual,
baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline,
diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? []
}
})),
diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? []
};
}
const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']);
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function nestedStrings(value: unknown): string[] {
if (typeof value === 'string') return [value];
if (Array.isArray(value)) return value.flatMap(nestedStrings);
if (!value || typeof value !== 'object') return [];
return Object.values(value).flatMap(nestedStrings);
}
function privateLiterals(item: UnifiedResultProjection): string[] {
const values = [
item.affectedAsset, item.reviewOwner, item.projectId, item.assessmentId,
item.collectionRunId, item.scanRunId, item.importArtifactId, item.sourceViewId,
item.environmentSubjectIdentity, item.policyBackupInstanceId, item.policyGpoId,
item.policyNativeBackupId, ...nestedStrings(item.sourceLocator)
];
const expanded = values.flatMap(value => {
if (typeof value !== 'string') return [];
const trimmed = value.trim();
if (trimmed.length < 3) return [];
const spaced = trimmed.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
return spaced !== trimmed && spaced.length >= 3 ? [trimmed, spaced] : [trimmed];
});
return [...new Set(expanded)].sort((left, right) => right.length - left.length);
}
/**
* Keeps rule guidance useful while removing identifiers that scanner prose can repeat.
* Unknown sources never reach this path: their narrative remains fail-closed.
*/
function redactNarrative(item: UnifiedResultProjection, value?: string): string | undefined {
if (!value?.trim() || !narrativeTools.has(disclosureTool(item).id.replace(/-(?:ad|entra|combined)$/, ''))) return undefined;
let redacted = value.trim()
.replace(/<(?:br|\/p|\/li|\/div)\s*\/?>/giu, '\n')
.replace(/<\/?[a-z][^>]*>/giu, ' ')
.replace(/&nbsp;/giu, ' ')
.replace(/&amp;/giu, '&')
.replace(/&lt;/giu, '<')
.replace(/&gt;/giu, '>')
.replace(/&quot;/giu, '"')
.replace(/&#39;/giu, "'");
redacted = redacted
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]')
.replace(/\bS-1-5-21(?:-\d+){3,}\b/giu, '[sid]')
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[id]')
.replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]')
.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, '[account]')
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]')
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]')
.replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]')
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, '[ip]')
.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]')
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, '[domain]')
.replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]');
for (const literal of privateLiterals(item)) {
redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]');
}
redacted = redacted
.replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/\s*\n\s*/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return redacted || undefined;
}
function controlledCategory(item: UnifiedResultProjection): UnifiedResultCategory[] {
if (item.sourceKind === 'environment-observation') return item.categories;
if (item.sourceKind === 'policy-evaluation') {
const code = item.policyClassification ?? 'comparison';
const labels: Record<string, string> = {
misconfigured: 'Deviation', 'not-configured': 'Not configured', match: 'Passed',
conflict: 'Conflicts', duplicate: 'Duplicates', 'review-required': 'Review required', comparison: 'Comparison'
};
return [{ namespace: 'group-policy', categoryCode: code, categoryLabel: labels[code] ?? 'Comparison',
categoryType: 'canonical', isPrimary: true, sourceTool: 'GPOs' }];
}
const tool = disclosureTool(item);
return [{ namespace: 'source-tool', categoryCode: tool.id, categoryLabel: tool.label,
categoryType: 'canonical', isPrimary: true, sourceTool: tool.id }];
}
function controlledTitle(item: UnifiedResultProjection): string {
if (item.sourceKind === 'environment-observation') return item.title;
if (item.sourceKind === 'policy-evaluation') {
if (item.title.trim()) return item.title;
if (item.policyClassification === 'conflict') return 'Group Policy consistency conflict';
if (item.policyClassification === 'duplicate') return 'Group Policy duplicate configuration';
if (item.policyDeliveryMechanism === 'advanced-audit-policy') return 'Group Policy audit-policy comparison';
if (item.policyDeliveryMechanism === 'security-template') return 'Group Policy security-setting comparison';
return 'Group Policy registry-setting comparison';
}
const tool = disclosureTool(item);
const ruleId = safePublicRuleId(item);
const title = redactNarrative(item, item.title);
if (title) return title;
return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`;
}
function controlledDescription(item: UnifiedResultProjection): string | undefined {
if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description);
if (item.policyClassification === 'conflict') {
return 'Different retained technical states were detected for the same Group Policy setting.';
}
if (item.policyClassification === 'duplicate') {
return 'The same technical state was detected in multiple Group Policy sources.';
}
return 'Configured Group Policy value compared with the retained baseline recommendation. Backup comparison; effective endpoint state not verified.';
}
function controlledDiagnosticCodes(values?: string[]): string[] | undefined {
const safe = values?.filter(value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value));
return safe?.length ? safe : undefined;
}
/**
* Fail-closed public projection used by Result DB and every downstream LLM/export path.
* Internal analysis keeps the canonical source records and provenance unchanged.
*/
export function discloseResultDbProjection(item: UnifiedResultProjection): UnifiedResultProjection {
const tool = disclosureTool(item);
const policyEvaluation = item.sourceKind === 'policy-evaluation';
const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined;
const rawPublicRuleId = safePublicRuleId(item);
const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId;
const policyDescription = policyRedactor ? safePolicyNarrative(item, policyRedactor, item.description) : undefined;
const publicActualValue = policyRedactor ? policyRedactor.json(item.policyActualValue) : undefined;
const publicBaselineValue = policyRedactor ? policyRedactor.json(item.policyBaselineValue) : undefined;
const publicBaselineVariants = policyRedactor
? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor)
: undefined;
return {
...item,
sourceTool: tool.id,
sourceToolVersion: undefined,
sourceProfileId: tool.id,
sourceRuleId: publicRuleId,
canonicalDefinitionId: undefined,
importArtifactId: undefined,
sourceViewId: undefined,
sourceLocator: undefined,
environmentSubjectIdentity: undefined,
environmentOriginalValue: undefined,
environmentAttributes: undefined,
environmentSectionKey: undefined,
policyValues: undefined,
policyBackupInstanceId: undefined,
policyGpoId: undefined,
policyNativeBackupId: undefined,
policySourceSequence: undefined,
policyIdentityKey: undefined,
policyBaselineId: undefined,
policyBaselineLabel: policyRedactor ? safeBaselineLabel(item, policyRedactor) : undefined,
policyBaselineValue: publicBaselineValue,
policyBaselineVariantAssessment: publicBaselineVariants,
policyActualValue: publicActualValue,
policyDiagnosticCodes: controlledDiagnosticCodes(item.policyDiagnosticCodes),
title: policyRedactor ? policyRedactor.text(controlledTitle(item)) : controlledTitle(item),
description: policyEvaluation
? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ')
: controlledDescription(item),
recommendation: policyRedactor
? safePolicyNarrative(item, policyRedactor, item.recommendation)
: redactNarrative(item, item.recommendation),
affectedAsset: undefined,
severityOriginal: item.severityNormalized,
outcomeOriginal: item.outcomeNormalized,
reviewOwner: undefined,
reviewDueDate: undefined,
sourceObservedAt: undefined,
importedAt: SAFE_TIMESTAMP,
evaluatedAt: undefined,
createdAt: SAFE_TIMESTAMP,
categories: controlledCategory(item)
};
}
/** Remove scope handles only after filtering/paging has completed. */
export function removeResultDbScopeIdentifiers(item: UnifiedResultProjection): UnifiedResultProjection {
return {
...item,
projectId: 'scoped-project',
assessmentId: item.assessmentId ? 'scoped-assessment' : undefined,
collectionRunId: 'scoped-collection',
scanRunId: item.scanRunId ? 'scoped-scan' : undefined,
gpoSnapshotId: undefined,
evaluationRunId: undefined
};
}
export function resultDbDisclosureTool(value?: string): string {
return safeTool(value).id;
}

View File

@ -77,6 +77,8 @@ export interface UnifiedResultProjection {
gpoSnapshotId?: string; gpoSnapshotId?: string;
evaluationRunId?: string; evaluationRunId?: string;
sourceTool?: string; sourceTool?: string;
/** Controlled scanner scope used to distinguish products sharing one canonical profile. */
sourceToolScope?: 'active-directory' | 'entra-id' | 'combined';
sourceToolVersion?: string; sourceToolVersion?: string;
sourceProfileId?: string; sourceProfileId?: string;
sourceProfileVersion?: string; sourceProfileVersion?: string;
@ -249,6 +251,26 @@ export interface ScannerUnifiedResultSource {
cveEnrichment?: CveEnrichmentRecord; cveEnrichment?: CveEnrichmentRecord;
} }
function scannerSourceToolScope(source: ScannerUnifiedResultSource): UnifiedResultProjection['sourceToolScope'] {
if (source.occurrence.sourceIdentity.tool.id !== 'purple-knight') return undefined;
const name = source.scanRun.tool.name.trim().toLocaleLowerCase('en-US');
const activeDirectory = name.includes('active directory');
const entra = name.includes('entra');
if (activeDirectory && entra) return 'combined';
if (entra) return 'entra-id';
if (activeDirectory) return 'active-directory';
return undefined;
}
export function resultSourceToolLabel(value?: string): string {
const labels: Record<string, string> = {
'purple-knight-ad': 'Purple Knight for Active Directory',
'purple-knight-entra': 'Purple Knight for Entra ID',
'purple-knight-combined': 'Purple Knight for Active Directory and Entra ID'
};
return value ? labels[value] ?? value : '–';
}
export interface PolicyUnifiedResultSource { export interface PolicyUnifiedResultSource {
comparison?: PolicyEvaluationResult; comparison?: PolicyEvaluationResult;
definition: FindingDefinition; definition: FindingDefinition;
@ -385,6 +407,7 @@ export function createScannerUnifiedResult(source: ScannerUnifiedResultSource):
collectionRunId: source.scanRun.id, collectionRunId: source.scanRun.id,
scanRunId: source.scanRun.id, scanRunId: source.scanRun.id,
sourceTool: source.occurrence.sourceIdentity.tool.id, sourceTool: source.occurrence.sourceIdentity.tool.id,
sourceToolScope: scannerSourceToolScope(source),
sourceToolVersion: source.occurrence.sourceIdentity.tool.version, sourceToolVersion: source.occurrence.sourceIdentity.tool.version,
sourceProfileId: source.importFile.processedWith.profileId, sourceProfileId: source.importFile.processedWith.profileId,
sourceProfileVersion: source.importFile.processedWith.profileVersion, sourceProfileVersion: source.importFile.processedWith.profileVersion,
@ -1081,7 +1104,12 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
total: filtered.length, total: filtered.length,
facets: [ facets: [
facet('sourceKind', 'Origin Type', item => item.sourceKind, 'sourceKind'), facet('sourceKind', 'Origin Type', item => item.sourceKind, 'sourceKind'),
facet('sourceTool', 'Source Tool', item => item.sourceTool, 'sourceTool'), (() => {
const result = facet('sourceTool', 'Source Tool', item => item.sourceTool, 'sourceTool');
result.values.forEach(value => { value.label = resultSourceToolLabel(value.value); });
result.values.sort((left, right) => left.label.localeCompare(right.label));
return result;
})(),
facet('environmentState', 'Environment State', item => item.environmentState, 'environmentState'), facet('environmentState', 'Environment State', item => item.environmentState, 'environmentState'),
facet('environmentSubjectType', 'Environment Subject', item => item.environmentSubjectType, 'environmentSubjectType'), facet('environmentSubjectType', 'Environment Subject', item => item.environmentSubjectType, 'environmentSubjectType'),
facet('environmentProperty', 'Environment Property', item => item.environmentProperty, 'environmentProperty'), facet('environmentProperty', 'Environment Property', item => item.environmentProperty, 'environmentProperty'),

View File

@ -3,9 +3,10 @@ import type {
UnifiedResultQuery, UnifiedResultQuery,
UnifiedResultQueryResult UnifiedResultQueryResult
} from '../domain/unified-result'; } from '../domain/unified-result';
import { discloseResultDbProjection, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../domain/result-db-disclosure';
export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl'; export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl';
export const RESULT_DB_JSONL_SCHEMA_VERSION = '1.1.0'; export const RESULT_DB_JSONL_SCHEMA_VERSION = '2.0.0';
export const RESULT_DB_EXPORT_PAGE_SIZE = 1000; export const RESULT_DB_EXPORT_PAGE_SIZE = 1000;
type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>; type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>;
@ -15,30 +16,37 @@ function definedQueryEntries(query: UnifiedResultQuery): Array<[string, unknown]
} }
function exportScope(query: UnifiedResultQuery): Record<string, unknown> { function exportScope(query: UnifiedResultQuery): Record<string, unknown> {
const scopeKeys = new Set(['projectId', 'assessmentId', 'scanRunId', 'scanRunIds', 'evaluationRunId']); return {
return Object.fromEntries(definedQueryEntries(query).filter(([key]) => scopeKeys.has(key))); projectScoped: Boolean(query.projectId),
assessmentScopeCount: query.assessmentIds?.length ?? (query.assessmentId ? 1 : 0),
scanScopeCount: query.scanRunIds?.length ?? (query.scanRunId ? 1 : 0),
evaluationScoped: Boolean(query.evaluationRunId)
};
} }
function exportFilters(query: UnifiedResultQuery): Record<string, unknown> { function exportFilters(query: UnifiedResultQuery): Record<string, unknown> {
const excluded = new Set(['projectId', 'assessmentId', 'scanRunId', 'scanRunIds', 'evaluationRunId', 'offset', 'limit']); const excluded = new Set([
return Object.fromEntries(definedQueryEntries(query).filter(([key]) => !excluded.has(key))); 'projectId', 'assessmentId', 'assessmentIds', 'scanRunId', 'scanRunIds', 'evaluationRunId',
'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo'
]);
return Object.fromEntries(definedQueryEntries(query)
.filter(([key]) => !excluded.has(key))
.map(([key, value]) => [key, Array.isArray(value) ? { active: true, selectionCount: value.length } : value]));
} }
function resultForExport(result: UnifiedResultProjection): UnifiedResultProjection { function resultForExport(result: UnifiedResultProjection): Record<string, unknown> {
if (result.sourceKind !== 'environment-observation') return result; const disclosed = discloseResultDbProjection(result);
if (!result.supplementalDisclosurePolicyVersion || !result.supplementalDisclosureClass) { if (disclosed.sourceKind === 'environment-observation'
&& (!disclosed.supplementalDisclosurePolicyVersion || !disclosed.supplementalDisclosureClass)) {
throw new Error('An unclassified supplemental result cannot be exported.'); throw new Error('An unclassified supplemental result cannot be exported.');
} }
const disclosed = { ...result }; const blocked = new Set([
delete disclosed.affectedAsset; 'resultProjectionId', 'sourceResultId', 'projectId', 'assessmentId', 'collectionRunId', 'scanRunId',
delete disclosed.environmentAttributes; 'gpoSnapshotId', 'evaluationRunId', 'importArtifactId', 'sourceViewId', 'sourceLocator',
delete disclosed.environmentOriginalValue; 'sourceObservedAt', 'importedAt', 'evaluatedAt', 'createdAt'
delete disclosed.environmentSectionKey; ]);
delete disclosed.environmentSubjectIdentity; return Object.fromEntries(Object.entries(disclosed)
delete disclosed.sourceLocator; .filter(([key, value]) => !blocked.has(key) && value !== undefined));
delete disclosed.sourceObservedAt;
delete disclosed.sourceViewId;
return disclosed;
} }
export function buildUnifiedResultJsonl( export function buildUnifiedResultJsonl(
@ -50,6 +58,7 @@ export function buildUnifiedResultJsonl(
recordType: 'manifest', recordType: 'manifest',
format: RESULT_DB_JSONL_FORMAT, format: RESULT_DB_JSONL_FORMAT,
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
generatedAt, generatedAt,
resultCount: results.length, resultCount: results.length,
scope: exportScope(query), scope: exportScope(query),

View File

@ -44,7 +44,7 @@ const EXPECTED = /^(?:baseline value|expected(?: value| result)?|desired(?: valu
const SOLUTION = /^(?:remediation(?: guidance| recommendation| action)?|advised solution|solution|recommendation|resolution)$/i; const SOLUTION = /^(?:remediation(?: guidance| recommendation| action)?|advised solution|solution|recommendation|resolution)$/i;
const AUDIT = /^(?:audit(?: procedure)?|verification|verify|test procedure)$/i; const AUDIT = /^(?:audit(?: procedure)?|verification|verify|test procedure)$/i;
const GUIDANCE = /^(?:description|rationale|impact|technical explanation|documentation|references?|notes)$/i; const GUIDANCE = /^(?:description|rationale|impact|technical explanation|documentation|references?|notes)$/i;
const CONTEXT = /^(?:id|rule id|shortname|technical identity|scope|target|registry path|baseline|policy type|policy section|imported|evaluated|source: .+|source file|source sheet)$/i; const CONTEXT = /^(?:id|rule id|source rule id|shortname|technical identity|scope|target|registry path|baseline|policy type|policy section|imported|evaluated|source: .+|source file|source sheet)$/i;
const TITLES = /^(?:title|name|finding|benchmark item|check title)$/i; const TITLES = /^(?:title|name|finding|benchmark item|check title)$/i;
const NEUTRAL = new Set(['Not configured', 'No baseline rule', 'Not comparable', 'Review required']); const NEUTRAL = new Set(['Not configured', 'No baseline rule', 'Not comparable', 'Review required']);
const GPO_SCOPE = 'Vergleich importierter GPO-Backups, kein Nachweis der wirksamen Endpoint-Konfiguration. Baseline-Varianten sind getrennte Empfehlungen; ihre Anwendbarkeit und Zielrolle vor einer Änderung bestätigen. Keine GPO-Priorität aus der Quellenreihenfolge ableiten.'; const GPO_SCOPE = 'Vergleich importierter GPO-Backups, kein Nachweis der wirksamen Endpoint-Konfiguration. Baseline-Varianten sind getrennte Empfehlungen; ihre Anwendbarkeit und Zielrolle vor einer Änderung bestätigen. Keine GPO-Priorität aus der Quellenreihenfolge ableiten.';

View File

@ -624,18 +624,27 @@ function restrictedSections(snapshot: EnvironmentSnapshot | undefined): string[]
.map(section => section.label); .map(section => section.label);
} }
export function dashboardMaesterTenantCount(snapshot?: EnvironmentSnapshot): number | undefined { export function dashboardEntraTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
const tenants = new Set((snapshot?.observations ?? []) const observed = (snapshot?.observations ?? []).filter(observation => observation.state === 'observed');
.filter(observation => observation.tool.id === 'maester' && observation.property === 'entra.tenant_id' && observation.state === 'observed') const ids = new Set(observed.filter(observation => observation.property === 'entra.tenant_id')
.map(observation => displayValue(observation).trim()).filter(Boolean)); .map(observation => displayValue(observation).trim()).filter(Boolean));
return tenants.size || undefined; if (ids.size) return ids.size;
const names = new Set(observed.filter(observation => observation.property === 'entra.tenant_name')
.map(observation => observation.subject.identity?.trim() || displayValue(observation).trim()).filter(Boolean));
return names.size || undefined;
} }
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail { export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header); const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
const categoryScores = (snapshot?.observations ?? []) const categoryScores = (snapshot?.observations ?? [])
.filter(observation => observation.property.startsWith('purple_knight.ad.category_score.')) .filter(observation => /^purple_knight\.(?:ad|entra)\.category_score\./.test(observation.property))
.map(observation => ({ label: observation.label, value: numberFact(optionalObservationNumber(snapshot, observation.property)) })); .map(observation => ({
label: `Purple Knight ${observation.property.startsWith('purple_knight.entra.') ? 'Entra' : 'AD'} · ${observation.label}`,
value: numberFact(optionalObservationNumber(snapshot, observation.property))
}));
const entraDirectoryValue = observationFor(snapshot, 'entra.tenant_name')
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
return { return {
operatingSystems: operatingSystemInventory(snapshot), operatingSystems: operatingSystemInventory(snapshot),
directory: [ directory: [
@ -647,7 +656,7 @@ export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): Dash
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') }, { label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') }, { label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') }, { label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected') } { label: 'Entra ID', value: entraDirectoryValue }
], ],
inventory: [ inventory: [
{ label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) }, { label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) },
@ -685,9 +694,13 @@ export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): Dash
toolContext: [ toolContext: [
{ label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') }, { label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') },
{ label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) }, { label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) },
{ label: 'Purple Knight posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) }, { label: 'Purple Knight AD posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
{ label: 'Purple Knight IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) }, { label: 'Purple Knight AD IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
{ label: 'Purple Knight evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) }, { label: 'Purple Knight AD evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) },
{ label: 'Purple Knight Entra tenant', value: observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') },
{ label: 'Purple Knight Entra posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.security_posture_score')) },
{ label: 'Purple Knight Entra IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.ioes_found')) },
{ label: 'Purple Knight Entra evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.evaluated')) },
...categoryScores, ...categoryScores,
{ label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') }, { label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') },
{ label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') }, { label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') },
@ -706,6 +719,9 @@ export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): Dash
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt. * Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
*/ */
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] { export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
const entra = observationFor(snapshot, 'entra.tenant_name')
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
return [ return [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') }, { label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') }, { label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
@ -716,7 +732,7 @@ export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): Dash
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') }, { label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') }, { label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') }, { label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected') } { label: 'Entra ID', value: entra }
]; ];
} }
@ -1380,7 +1396,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i); const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count'); const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label)); const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const entraTenants = dashboardMaesterTenantCount(environment.snapshot) const entraTenants = dashboardEntraTenantCount(environment.snapshot)
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') ?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
?? (maesterSource?.records.length ? 1 : undefined); ?? (maesterSource?.records.length ? 1 : undefined);
const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount; const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount;

View File

@ -1,17 +1,15 @@
import type { ReactNode } from 'react'; import { useRef, useState, type ReactNode } from 'react';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
import type { OperatingSystemInventoryGroup } from './operating-system-inventory'; import type { OperatingSystemInventoryGroup } from './operating-system-inventory';
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US'); const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
export function OperatingSystemInventory({ groups, icon }: { groups: OperatingSystemInventoryGroup[]; icon?: ReactNode }) { function OperatingSystemGroups({ groups, inventories }: { groups: OperatingSystemInventoryGroup[]; inventories: string[] }) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))]; return <div className="client-os-groups">{groups.map(group => <section className="client-os-group" key={group.label}>
return <section className="client-os-inventory" aria-label="Operating systems">
<header><h3>{icon}<span>Operating systems</span></h3>{inventories.length > 1 && <p>Source inventories are listed separately; overlapping counts are not added.</p>}</header>
<div className="client-os-groups">{groups.map(group => <section className="client-os-group" key={group.label}>
<h4><span>{group.label}</span><span aria-label={`${group.label} total`}>{quantity(group.total)}</span></h4> <h4><span>{group.label}</span><span aria-label={`${group.label} total`}>{quantity(group.total)}</span></h4>
{group.entries.length ? <table><thead><tr> {group.entries.length ? <table><thead><tr>
<th scope="col">Operating system</th> <th scope="col">Operating system</th>
{inventories.length > 1 && <th className="client-os-source" scope="col">Source</th>} {inventories.length > 1 && <th className="client-os-source" scope="col">Inventory</th>}
<th className="client-os-count" scope="col">Devices</th> <th className="client-os-count" scope="col">Devices</th>
<th className="client-os-count" scope="col">Active</th> <th className="client-os-count" scope="col">Active</th>
<th className="client-os-count" scope="col">Inactive</th> <th className="client-os-count" scope="col">Inactive</th>
@ -28,6 +26,27 @@ export function OperatingSystemInventory({ groups, icon }: { groups: OperatingSy
<td className="client-os-count">{quantity(entry.disabled)}</td> <td className="client-os-count">{quantity(entry.disabled)}</td>
</tr>)}</tbody></table> </tr>)}</tbody></table>
: <p className="client-os-empty">No classified inventory available</p>} : <p className="client-os-empty">No classified inventory available</p>}
</section>)}</div> </section>)}</div>;
}
export function OperatingSystemInventory({ groups, icon }: { groups: OperatingSystemInventoryGroup[]; icon?: ReactNode }) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
const [windowOpen, setWindowOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeWindow = () => {
setWindowOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
return <section className="client-os-inventory" aria-label="Operating systems">
<header>
<h3>{icon}<span>Operating systems</span></h3>
<button ref={triggerRef} className={`btn results-btn client-os-results-button${windowOpen ? ' is-open' : ''}`} type="button"
onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen}
aria-label="Open Operating systems results" title="Open results">Results</button>
</header>
<OperatingSystemGroups groups={groups} inventories={inventories} />
{windowOpen && <FloatingDataWindow title="Operating systems" onClose={closeWindow}>
<OperatingSystemGroups groups={groups} inventories={inventories} />
</FloatingDataWindow>}
</section>; </section>;
} }

View File

@ -89,9 +89,12 @@ export function operatingSystemInventory(snapshot?: EnvironmentSnapshot): Operat
const unique = entries.map(entry => duplicates.get(JSON.stringify([entry.inventory, entry.name]))! > 1 const unique = entries.map(entry => duplicates.get(JSON.stringify([entry.inventory, entry.name]))! > 1
? { ...entry, count: undefined } : entry); ? { ...entry, count: undefined } : entry);
const deduplicated = [...new Map(unique.map(entry => [JSON.stringify([entry.inventory, entry.name]), entry])).values()]; const deduplicated = [...new Map(unique.map(entry => [JSON.stringify([entry.inventory, entry.name]), entry])).values()];
const inventories = new Set(deduplicated.map(entry => entry.inventory)); const inventories = new Map<string, OperatingSystemEntry[]>();
const total = deduplicated.length > 0 && inventories.size === 1 && deduplicated.every(entry => entry.count !== undefined) for (const entry of deduplicated) inventories.set(entry.inventory, [...(inventories.get(entry.inventory) ?? []), entry]);
? deduplicated.reduce((sum, entry) => sum + entry.count!, 0) : undefined; const quantifiedInventories = [...inventories.values()].filter(items => items.some(entry => entry.count !== undefined));
const completeInventory = quantifiedInventories.length === 1
&& quantifiedInventories[0].every(entry => entry.count !== undefined) ? quantifiedInventories[0] : undefined;
const total = completeInventory?.reduce((sum, entry) => sum + entry.count!, 0);
return { label, entries: deduplicated.sort((a, b) => (b.count ?? -1) - (a.count ?? -1) || a.name.localeCompare(b.name)), return { label, entries: deduplicated.sort((a, b) => (b.count ?? -1) - (a.count ?? -1) || a.name.localeCompare(b.name)),
total: total !== undefined && Number.isSafeInteger(total) ? total : undefined }; total: total !== undefined && Number.isSafeInteger(total) ? total : undefined };
}); });

View File

@ -5,9 +5,9 @@ export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain';
export const securityDomainCatalog = [ export const securityDomainCatalog = [
{ id: 'identity', label: 'Identity & Account Hygiene', glyph: 'user', pattern: /account|identity|user|password|stale|inactive|honey\s*pot/i }, { id: 'identity', label: 'Identity & Account Hygiene', glyph: 'user', pattern: /account|identity|user|password|stale|inactive|honey\s*pot/i },
{ id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i }, { id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i },
{ id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass/i }, { id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass|\bldaps?\b|channel\s*binding|anonymous\s*binding|rootdse|tls\s*1\.[01]/i },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema/i }, { id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema|organizational\s*units?|\bous?\b.*accidental\s*deletion|\bntfrs\b|\bsysvol\b/i },
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|firewall|attack\s*surface|\basr\b|defender|operating\s*system/i }, { id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|hardened\s*paths?|firewall|print\s*spooler|\bspooler\b|attack\s*surface|\basr\b|defender|operating\s*system|obsolete\s+os|unsupported\s+(?:os|operating\s*system)|script\s*engines?|\bcve-\d+/i },
{ id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i }, { id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i },
{ id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i }, { id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i },
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i } { id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i }
@ -15,17 +15,38 @@ export const securityDomainCatalog = [
export const unclassifiedSecurityDomain = { id: 'unclassified', label: 'Unclassified' } as const; export const unclassifiedSecurityDomain = { id: 'unclassified', label: 'Unclassified' } as const;
const recordDomainEvidenceCache = new WeakMap<FindingRecord, { text: string; explicitGpo: boolean }>(); const MAESTER_CLOUD_SUITE = /(?:^|[\s,;|\["'])(?:orca(?:\.|\b)|exo(?=$|[\s,;|\]"'])|eidsca(?:\.|\b)|cisa\.ms\.(?:exo|aad)(?:\.|\b)|maester\/(?:exchange|teams|entra)(?=$|[\s,;|\]"']))/i;
function recordDomainEvidence(record: FindingRecord): { text: string; explicitGpo: boolean } { const recordDomainEvidenceCache = new WeakMap<FindingRecord, {
text: string;
narrativeText: string;
explicitGpo: boolean;
scannerSuiteText: string;
}>();
function recordDomainEvidence(record: FindingRecord): {
text: string;
narrativeText: string;
explicitGpo: boolean;
scannerSuiteText: string;
} {
let cached = recordDomainEvidenceCache.get(record); let cached = recordDomainEvidenceCache.get(record);
if (cached === undefined) { if (cached === undefined) {
const fields = record.fields; const fields = record.fields;
cached = { cached = {
text: [record.title, ...fields text: [record.title, ...fields
.filter(field => /^(?:title|name|finding|description|category|subcategory|sub-category|categories|control area|policy section|policy type)$/i.test(field.label.trim())) .filter(field => /^(?:title|name|finding|description|category|subcategory|sub-category|categories|control area|policy section|policy type|source rule id)$/i.test(field.label.trim()))
.map(field => field.value)].join(' '), .map(field => field.value)].join(' '),
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim())) narrativeText: fields
.filter(field => /^(?:technical explanation|rationale|recommendation|remediation(?: guidance| recommendation)?|advised solution|solution|impact|details|test details)$/i.test(field.label.trim()))
.map(field => field.value).join(' '),
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim())),
// Maester exposes stable suite/control identifiers in BLOCK, ID and TAG.
// They are fallback evidence only: descriptive security-domain matches
// above retain priority and the source values remain unchanged.
scannerSuiteText: [record.title, ...fields
.filter(field => /^(?:block|id|tag|tags|category|categories)$/i.test(field.label.trim()))
.map(field => field.value)].join(' ')
}; };
recordDomainEvidenceCache.set(record, cached); recordDomainEvidenceCache.set(record, cached);
} }
@ -36,7 +57,15 @@ function recordDomainEvidence(record: FindingRecord): { text: string; explicitGp
export function securityDomainIdForRecord(record: FindingRecord): string { export function securityDomainIdForRecord(record: FindingRecord): string {
const evidence = recordDomainEvidence(record); const evidence = recordDomainEvidence(record);
if (evidence.explicitGpo) return 'gpo'; if (evidence.explicitGpo) return 'gpo';
return securityDomainCatalog.find(domain => domain.pattern.test(evidence.text))?.id ?? unclassifiedSecurityDomain.id; const descriptiveMatch = securityDomainCatalog.find(domain => domain.pattern.test(evidence.text));
if (descriptiveMatch) return descriptiveMatch.id;
// Broader narrative fields are useful fallback evidence, but they may mention
// several domains in one remediation. Assign only when the evidence is unique;
// otherwise keeping Unclassified is more honest than inventing a category.
const narrativeMatches = securityDomainCatalog.filter(domain => domain.pattern.test(evidence.narrativeText));
if (narrativeMatches.length === 1) return narrativeMatches[0].id;
if (MAESTER_CLOUD_SUITE.test(evidence.scannerSuiteText)) return 'cloud';
return unclassifiedSecurityDomain.id;
} }
/** Assign every record once so Client Info distributions reconcile to the total. */ /** Assign every record once so Client Info distributions reconcile to the total. */

View File

@ -10,6 +10,7 @@ import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { DatasetSheet } from '../../domain/dataset'; import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { resultReferenceValues } from '../../profiles/purple-knight/result-linker'; import { resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { maesterFindingTitle } from '../../profiles/maester/presentation';
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links'; import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { recordResultSheets } from '../linked-results/record-results'; import { recordResultSheets } from '../linked-results/record-results';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
@ -207,8 +208,12 @@ export function DatasetViewer() {
if (!dataset || !activeSheet) return <EmptyWorkspace />; if (!dataset || !activeSheet) return <EmptyWorkspace />;
const recordTitle = (record: FindingRecord) => const recordTitle = (record: FindingRecord) => {
recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId); const candidate = recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId);
return toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId) === 'maester'
? maesterFindingTitle(record, candidate)
: candidate;
};
const selected = records.find(record => record.id === selectedRecordId) ?? records[0]; const selected = records.find(record => record.id === selectedRecordId) ?? records[0];
const toolId = activeSheet.toolId ?? dataset.profileId; const toolId = activeSheet.toolId ?? dataset.profileId;
const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId); const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId);

View File

@ -31,6 +31,7 @@ import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_D
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingPanel } from './RegulatoryMappingPanel'; import { RegulatoryMappingPanel } from './RegulatoryMappingPanel';
import { useRegulatoryMapping } from './use-regulatory-mapping'; import { useRegulatoryMapping } from './use-regulatory-mapping';
import { maesterExecutionErrorText, maesterTagText, maesterTestResultText } from '../../profiles/maester/presentation';
interface Props { interface Props {
record: FindingRecord; record: FindingRecord;
@ -71,7 +72,7 @@ const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLo
*/ */
const isAdditionalOnlyField = (label: string) => { const isAdditionalOnlyField = (label: string) => {
const normalized = normalizeLabel(label); const normalized = normalizeLabel(label);
return /^(?:short\s*name|si version|target|runtime|points scale|rule id|policy type|policy section|description source)$/.test(normalized) return /^(?:short\s*name|si version|target|runtime|points scale|rule id|source rule id|policy type|policy section|description source)$/.test(normalized)
|| /^(?:quelle|source):? /.test(normalized) || /^(?:quelle|source):? /.test(normalized)
|| /^anssi \d+$/.test(normalized); || /^anssi \d+$/.test(normalized);
}; };
@ -192,7 +193,10 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const derivedCategories = cisBenchmarkCategories(record, toolId); const derivedCategories = cisBenchmarkCategories(record, toolId);
const legacyFramework = legacyPurpleKnightFrameworkFields(record, toolId); const legacyFramework = legacyPurpleKnightFrameworkFields(record, toolId);
const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index)); const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
const { optionalFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes); const { optionalFields: cisCatFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
const optionalFields = [...cisCatFields];
const sourceRuleId = record.fields.find(field => normalizeLabel(field.label) === 'source rule id' && field.value.trim());
if (sourceRuleId) hiddenFieldIndexes.add(sourceRuleId.index);
legacyFramework.sourceIndexes.forEach(index => hiddenFieldIndexes.add(index)); legacyFramework.sourceIndexes.forEach(index => hiddenFieldIndexes.add(index));
// Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der // Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der
// Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im // Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im
@ -224,6 +228,31 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
.forEach(field => hiddenFieldIndexes.add(field.index)); .forEach(field => hiddenFieldIndexes.add(field.index));
} }
const statusField = resultStatusFieldFor(record, toolId); const statusField = resultStatusFieldFor(record, toolId);
const maesterPresentationFields: RecordField[] = [];
if (toolId === 'maester') {
let presentationIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
const displayField = (source: RecordField, label: string, value = source.value): RecordField => ({
...source, index: presentationIndex++, label, value
});
const byLabel = (label: string) => record.fields.find(field => normalizeLabel(field.label) === label && field.value);
const id = byLabel('id');
const detail = byLabel('resultdetail');
const script = byLabel('testscript');
const category = byLabel('block');
const tags = byLabel('tag');
const source = byLabel('testscriptfile');
const error = byLabel('errormessage');
record.fields.filter(field => /^(?:id|result|resultdetail|severity|tag|block|duration|errormessage|name|helpurl|testscriptfile|testscript|index)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (!sourceRuleId && id) maesterPresentationFields.push(displayField(id, 'ID'));
if (statusField?.value) maesterPresentationFields.push(displayField(statusField, 'Test result', maesterTestResultText(statusField.value, detail?.value)));
if (script) maesterPresentationFields.push(displayField(script, 'Test details', script.value.trim()));
if (category) maesterPresentationFields.push(displayField(category, 'Category'));
if (tags) maesterPresentationFields.push(displayField(tags, 'Tags', maesterTagText(tags.value)));
if (source) maesterPresentationFields.push(displayField(source, 'Source'));
if (error) optionalFields.push({ ...error, label: 'Technical execution detail', value: maesterExecutionErrorText(error.value) });
}
const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0; const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0;
const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length); const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length);
if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label)) if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label))
@ -248,7 +277,14 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const summaryPriority = (label: string) => { const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label); const normalized = normalizeLabel(label);
if (normalized === 'conflict' || normalized === 'duplicates') return -1; if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (toolId === 'maester' && normalized === 'id') return -1;
if (normalized === 'result message') return 0; if (normalized === 'result message') return 0;
if (toolId === 'maester' && normalized === 'test result') return 0;
if (toolId === 'maester' && normalized === 'description') return 1;
if (toolId === 'maester' && normalized === 'test details') return 2;
if (toolId === 'maester' && normalized === 'category') return 3;
if (toolId === 'maester' && normalized === 'tags') return 4;
if (toolId === 'maester' && normalized === 'source') return 5;
if (/^number of results?$/.test(normalized)) return 1; if (/^number of results?$/.test(normalized)) return 1;
if (normalized === 'description') return 2; if (normalized === 'description') return 2;
if (toolId === 'pingcastle' && normalized === 'technical explanation') return 3; if (toolId === 'pingcastle' && normalized === 'technical explanation') return 3;
@ -260,10 +296,13 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
return 10; return 10;
}; };
const summaryValues = new Set<string>(); const summaryValues = new Set<string>();
const summaryFields = record.fields const presentationFields = [...record.fields, ...maesterPresentationFields];
const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index));
const summaryFields = presentationFields
.filter(field => { .filter(field => {
if (hiddenFieldIndexes.has(field.index)) return false; if (hiddenFieldIndexes.has(field.index)) return false;
if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label) || isHeadingOrBadgeField(field.label)) return false; if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label)
|| (!maesterPresentationIndexes.has(field.index) && isHeadingOrBadgeField(field.label))) return false;
if (hasStructuredResults && isRedundantDetailField(field.label)) return false; if (hasStructuredResults && isRedundantDetailField(field.label)) return false;
const value = normalizeValue(field.value); const value = normalizeValue(field.value);
if (summaryValues.has(value)) return false; if (summaryValues.has(value)) return false;
@ -280,7 +319,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
...(statusField ? [statusField.index] : []) ...(statusField ? [statusField.index] : [])
]); ]);
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue)); const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
const remainingFields = [...record.fields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => { const remainingFields = [...presentationFields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => {
if (!field.value || displayedIndexes.has(field.index)) return false; if (!field.value || displayedIndexes.has(field.index)) return false;
if (isAdditionalOnlyField(field.label)) return true; if (isAdditionalOnlyField(field.label)) return true;
if (isHeadingOrBadgeField(field.label)) return false; if (isHeadingOrBadgeField(field.label)) return false;
@ -294,7 +333,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const checkTitleIndex = summary.findIndex(field => normalizeLabel(field.label) === 'check title'); const checkTitleIndex = summary.findIndex(field => normalizeLabel(field.label) === 'check title');
const checkTitle = checkTitleIndex >= 0 ? summary.splice(checkTitleIndex, 1)[0] : undefined; const checkTitle = checkTitleIndex >= 0 ? summary.splice(checkTitleIndex, 1)[0] : undefined;
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness }; return { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness };
} }
/** /**
@ -355,7 +394,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
.filter(acceptance => acceptance.target.kind === 'device-finding' && acceptance.target.device.key === selectedDeviceTarget.device.key) .filter(acceptance => acceptance.target.kind === 'device-finding' && acceptance.target.device.key === selectedDeviceTarget.device.key)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] : undefined; .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] : undefined;
const webSearch = findingWebSearch(record, resolvedToolId); const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId); const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown'; const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const outcomeMark = findingOutcomeMark(record, toolId); const outcomeMark = findingOutcomeMark(record, toolId);
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean); const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
@ -364,6 +403,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
// Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten // Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten
// sonst ihren gesamten Inhalt ein zweites Mal als "Referenz" dort stehen. // sonst ihren gesamten Inhalt ein zweites Mal als "Referenz" dort stehen.
const fileReferences = [...new Set(resultReferences.filter(isFileReference))]; const fileReferences = [...new Set(resultReferences.filter(isFileReference))];
const hasResolvableResultReference = fileReferences.length > 0
|| resultReferences.some(reference => /source-table:\d+/i.test(reference));
const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value }); const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
const regulatoryMapping = useRegulatoryMapping(projectId, record.findingOccurrenceId, backendStatus); const regulatoryMapping = useRegulatoryMapping(projectId, record.findingOccurrenceId, backendStatus);
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined; const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
@ -445,6 +486,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
<label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label> <label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label>
</section> </section>
<section className="reader-summary"> <section className="reader-summary">
{sourceRuleId && <div data-source-rule-id><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
{Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews"> {Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews">
<summary>Previous baseline-specific reviews ({previousPolicyReviews!.length})</summary> <summary>Previous baseline-specific reviews ({previousPolicyReviews!.length})</summary>
<p>These earlier notes remain attached to their original comparisons. Different decisions and risk acceptances are not automatically applied to this combined setting.</p> <p>These earlier notes remain attached to their original comparisons. Different decisions and risk acceptances are not automatically applied to this combined setting.</p>
@ -471,9 +513,13 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</section>; </section>;
})} })}
</div>} </div>}
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label)) {summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
? <p className="policy-guidance-text"><PathText value={field.value} search={search} label={field.label} /></p> ? <p><code className="mono">{highlightText(field.value, search)}</code></p>
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)} : resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
? <pre className="rich-text-code">{highlightText(field.value, search)}</pre>
: resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label))
? <p className="policy-guidance-text"><PathText value={field.value} search={search} label={field.label} /></p>
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}> {optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary> <summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
<RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} /> <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />
@ -498,7 +544,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
Der Hinweis bleibt nur fuer den Fall, dass zwar eine Referenz Der Hinweis bleibt nur fuer den Fall, dass zwar eine Referenz
vorliegt, die zugehoerige Datei aber nicht geladen ist - dann ist die vorliegt, die zugehoerige Datei aber nicht geladen ist - dann ist die
Schaltflaeche abgeschaltet und der Hinweis die einzige Erklaerung. */} Schaltflaeche abgeschaltet und der Hinweis die einzige Erklaerung. */}
{resultReferences.length > 0 && !linkedSheets.length && ( {hasResolvableResultReference && !linkedSheets.length && (
<details className="all-fields linked-results-inline" open={linkedResultsOpen} onToggle={event => setOpenLinkedResultsFor(event.currentTarget.open ? record.id : '')}> <details className="all-fields linked-results-inline" open={linkedResultsOpen} onToggle={event => setOpenLinkedResultsFor(event.currentTarget.open ? record.id : '')}>
<summary><TriangleIcon direction="right" />Results (reference found)</summary> <summary><TriangleIcon direction="right" />Results (reference found)</summary>
{linkedResultsOpen && ( {linkedResultsOpen && (

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, ImportFileRecord, ScanRun } from '../../domain/canonical-model'; import type { Assessment, AssessmentProject, CanonicalAssessmentModel, ImportFileRecord, ScanRun } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract'; import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
@ -725,8 +725,6 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const reports = editedReports.filter(report => group.scanRunIds.includes(report.scanRunId)); const reports = editedReports.filter(report => group.scanRunIds.includes(report.scanRunId));
const processingByRun = new Map(reports.map(report => [report.scanRunId, summarizeScanProcessingReadiness(report.files)])); const processingByRun = new Map(reports.map(report => [report.scanRunId, summarizeScanProcessingReadiness(report.files)]));
const affectedReports = reports.filter(report => processingByRun.get(report.scanRunId)?.state !== 'ready'); const affectedReports = reports.filter(report => processingByRun.get(report.scanRunId)?.state !== 'ready');
const hasProcessingError = affectedReports.some(report => processingByRun.get(report.scanRunId)?.state === 'error');
const hasProcessingReview = affectedReports.some(report => processingByRun.get(report.scanRunId)?.state === 'review');
const replacementByRun = new Map(affectedReports.flatMap(affected => { const replacementByRun = new Map(affectedReports.flatMap(affected => {
const candidate = reports const candidate = reports
.filter(report => report.scanRunId !== affected.scanRunId .filter(report => report.scanRunId !== affected.scanRunId
@ -781,13 +779,33 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
: readiness === 'not-viewable' ? 'The upload is stored, but it produced no records that Work can display.' : readiness === 'not-viewable' ? 'The upload is stored, but it produced no records that Work can display.'
: readiness === 'unavailable' ? 'Display readiness could not be checked.' : readiness === 'unavailable' ? 'Display readiness could not be checked.'
: 'Display readiness is being checked.'; : 'Display readiness is being checked.';
return <div className="tenant-report-row" role="row" key={report.id}> const replacement = replacementByRun.get(report.scanRunId);
<input type="checkbox" disabled={!viewable} checked={viewable && selected && !excludedScanRunIds.includes(report.scanRunId)} aria-label={`Select ${report.tool.name} scan report`} onChange={event => toggleScanReport(group.id, report.scanRunId, event.target.checked)}/> const processingHeading = processing.state === 'error' ? 'ASSESSOR processing issue'
<span className="tenant-report-copy"><b>{report.tool.name}</b><small>{report.fileCount.toLocaleString('de-DE')} {report.fileCount === 1 ? 'file' : 'files'} · {report.formats.join(', ') || '—'}</small></span> : processing.state === 'review' ? 'Review required'
<span className="tenant-report-imported">{formatHistoryDate(report.importedAt)}</span> : 'New ASSESSOR processing profile available';
<b className="tenant-report-status" data-status={processing.state === 'update' ? 'reimport-recommended' : processing.state === 'review' ? 'review-required' : processing.state === 'error' ? 'processing-error' : readiness} title={statusTitle}>{statusLabel}</b> return <Fragment key={report.id}>
<button className="btn tenant-scan-delete" type="button" title={`Delete ${report.tool.name} scan report`} aria-label={`Delete ${report.tool.name} scan report`} onClick={() => setScanDeleteTarget(report)}>Delete</button> <div className="tenant-report-row" role="row">
</div>; <input type="checkbox" disabled={!viewable} checked={viewable && selected && !excludedScanRunIds.includes(report.scanRunId)} aria-label={`Select ${report.tool.name} scan report`} onChange={event => toggleScanReport(group.id, report.scanRunId, event.target.checked)}/>
<span className="tenant-report-copy"><b>{report.tool.name}</b><small>{report.fileCount.toLocaleString('de-DE')} {report.fileCount === 1 ? 'file' : 'files'} · {report.formats.join(', ') || '—'}</small></span>
<span className="tenant-report-imported">{formatHistoryDate(report.importedAt)}</span>
<b className="tenant-report-status" data-status={processing.state === 'update' ? 'reimport-recommended' : processing.state === 'review' ? 'review-required' : processing.state === 'error' ? 'processing-error' : readiness} title={statusTitle}>{statusLabel}</b>
<button className="btn tenant-scan-delete" type="button" title={`Delete ${report.tool.name} scan report`} aria-label={`Delete ${report.tool.name} scan report`} onClick={() => setScanDeleteTarget(report)}>Delete</button>
</div>
{processing.state !== 'ready' && <section className="tenant-processing-alert tenant-processing-alert-inline"
data-status={processing.state} aria-label={`${report.tool.name}: ${processingHeading}`}>
<div><strong>{processingHeading}</strong><p>{replacement
? 'A newer ready scan of the same tool is present. It can be adopted as the replacement without deleting or uploading it again.'
: processing.explanation}</p></div>
{replacement ? <button className="btn primary" type="button" disabled={busy} onClick={() => void adoptReplacement(report, replacement)}>
Use imported {report.tool.name}
</button> : processing.state !== 'review' && <FileImport
targetProjectId={editedProject.id}
targetAssessmentId={report.assessmentIds[0]}
replacesScanRunId={report.scanRunId}
label={`Reimport ${report.tool.name}`}
/>}
</section>}
</Fragment>;
})} })}
{group.gpoBackups.map(record => <GpoStatusRow key={record.artifactId} record={record} projectId={form.project!.id} {group.gpoBackups.map(record => <GpoStatusRow key={record.artifactId} record={record} projectId={form.project!.id}
imported={formatHistoryDate(record.submittedAt)} busy={busy} imported={formatHistoryDate(record.submittedAt)} busy={busy}
@ -795,28 +813,6 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
onRetry={() => void retryGpoBackup(record)} onRetry={() => void retryGpoBackup(record)}
onDelete={() => setGpoDeleteTarget(record)} />)} onDelete={() => setGpoDeleteTarget(record)} />)}
</div>} </div>}
{affectedReports.length > 0 && <section className="tenant-processing-alert" data-status={hasProcessingError ? 'error' : hasProcessingReview ? 'review' : 'update'} aria-label={hasProcessingError ? 'ASSESSOR processing errors require attention' : hasProcessingReview ? 'ASSESSOR review required' : 'ASSESSOR processing profile updates available'}>
<header><strong>{hasProcessingError ? 'ASSESSOR processing issue' : hasProcessingReview ? 'Review required' : 'New ASSESSOR processing profile available'}</strong><span>{hasProcessingError
? 'At least one stored processing contract cannot be assessed safely. Review the affected scan and reimport its unchanged original file.'
: hasProcessingReview ? 'The report needs attention because its scanner profile is generic or client-owned finding data could not be matched uniquely during reimport. Original data remains retained.'
: 'ASSESSOR has updated its internal scanner-processing profile. Reimport the unchanged original scanner file to apply it; the existing scan remains active until replacement succeeds.'}</span></header>
{affectedReports.map(report => {
const replacement = replacementByRun.get(report.scanRunId);
return <div className="tenant-processing-alert-row" key={report.scanRunId}>
<div><strong>{report.tool.name}</strong><p>{replacement
? 'A newer ready scan of the same tool is present. It can be adopted as the replacement without deleting or uploading it again.'
: processingByRun.get(report.scanRunId)!.explanation}</p></div>
{replacement ? <button className="btn primary" type="button" disabled={busy} onClick={() => void adoptReplacement(report, replacement)}>
Use imported {report.tool.name}
</button> : processingByRun.get(report.scanRunId)!.state !== 'review' && <FileImport
targetProjectId={editedProject.id}
targetAssessmentId={report.assessmentIds[0]}
replacesScanRunId={report.scanRunId}
label={`Reimport ${report.tool.name}`}
/>}
</div>;
})}
</section>}
<div className="tenant-timeslot-actions"> <div className="tenant-timeslot-actions">
<div className="tenant-timeslot-add-heading"> <div className="tenant-timeslot-add-heading">
<h4>Add Entry</h4> <h4>Add Entry</h4>

View File

@ -8,6 +8,7 @@ import type { CveAffectedConfiguration, CveConfigurationMatch, CveEnrichmentReco
import { cveIdFromRuleId, cweDetailUrl } from '../../domain/cve-enrichment'; import { cveIdFromRuleId, cweDetailUrl } from '../../domain/cve-enrichment';
import { canonicalSecurityScores } from '../../scoring/canonical-security-score'; import { canonicalSecurityScores } from '../../scoring/canonical-security-score';
import { defaultSecurityScoringPolicy } from '../../domain/security-scoring'; import { defaultSecurityScoringPolicy } from '../../domain/security-scoring';
import { preferredPingCastleReportTitles, preferredPingCastleTitleForOccurrence } from '../../profiles/pingcastle/pingcastle-title-projection';
function text(value: JsonValue): string { function text(value: JsonValue): string {
if (value === null) return ''; if (value === null) return '';
@ -158,14 +159,91 @@ export function cveFieldsForDisplay(record: CveEnrichmentRecord): OriginalField[
return fields; return fields;
} }
/**
* Give every native scanner identity one stable, tool-independent presentation
* label in Work. The source field itself is only relabelled in this display
* projection; retained original fields and provenance remain unchanged.
*/
function withSourceRuleId(fields: OriginalField[], occurrence: FindingOccurrence): OriginalField[] {
const sourceRuleId = occurrence.sourceIdentity.originalRuleId?.trim()
|| occurrence.sourceIdentity.canonicalRuleId?.trim();
if (!sourceRuleId) return fields;
const sourceKeys = new Set(occurrence.fieldMappings
.filter(mapping => mapping.targetRole === 'rule-id')
.flatMap(mapping => mapping.sourceFieldKeys));
let found = false;
const projected = fields.map(field => {
if (field.label.trim().toLocaleLowerCase('en-US') === 'source rule id') {
found = true;
return field;
}
const ruleIdLabel = /^(?:id|rule\s*id|control\s*id|test\s*id|short\s*name|recommendation)$/i.test(field.label.trim());
if (!found && (sourceKeys.has(field.key) || ruleIdLabel) && fieldText(field).trim() === sourceRuleId) {
found = true;
return { ...field, label: 'Source Rule ID' };
}
return field;
});
return found ? projected : [...projected, {
key: 'derived-source-rule-id', label: 'Source Rule ID', value: sourceRuleId, path: 'derived/source-rule-id'
}];
}
/**
* Work and Result DB must lead with the same canonical finding definition.
* Scanner files can carry a shorter card/check title next to a more useful
* rationale title for the same Rule ID. Result DB already uses the canonical
* definition title; Work used to rebuild its heading from the occurrence's
* native `Title` field and could therefore show a different wording.
*
* This is a display-only projection. When the native title differs, it remains
* readable as `Native source heading`, while `Title` receives the canonical definition
* title used by Result DB. The retained occurrence and provenance are untouched.
*/
function withCanonicalResultTitle(
fields: OriginalField[],
occurrence: FindingOccurrence,
canonicalTitle: string | undefined
): OriginalField[] {
const title = canonicalTitle?.trim();
if (!title) return fields;
const sourceTitle = occurrence.sourceIdentity.originalTitle?.trim();
if (!sourceTitle || sourceTitle === title) return fields;
const titleKeys = new Set(occurrence.fieldMappings
.filter(mapping => mapping.targetRole === 'title')
.flatMap(mapping => mapping.sourceFieldKeys));
let projectedTitle = false;
const projected = fields.map(field => {
const isTitleField = titleKeys.has(field.key)
|| field.label.trim().toLocaleLowerCase('en-US') === 'title';
if (projectedTitle || !isTitleField || fieldText(field).trim() !== sourceTitle) return field;
projectedTitle = true;
return { ...field, label: 'Title', value: title };
});
if (!projectedTitle) projected.push({
key: 'derived-canonical-title', label: 'Title', value: title, path: 'derived/canonical-title'
});
projected.push({
key: 'derived-source-title', label: 'Native source heading', value: sourceTitle, path: 'derived/source-title'
});
return projected;
}
function displayFieldsByOccurrence( function displayFieldsByOccurrence(
occurrences: FindingOccurrence[], occurrences: FindingOccurrence[],
enrichments: ReadonlyMap<string, CveEnrichmentRecord> enrichments: ReadonlyMap<string, CveEnrichmentRecord>,
canonicalTitleByOccurrence: ReadonlyMap<FindingOccurrence, string>
): Map<string, OriginalField[]> { ): Map<string, OriginalField[]> {
return new Map(occurrences.map(occurrence => [ return new Map(occurrences.map(occurrence => [
occurrence.id, occurrence.id,
[ [
...withDerivedSeverity(withLinkedSectionIdOnly(occurrence.originalFields, occurrence.importFileId), occurrence), ...withDerivedSeverity(withSourceRuleId(
withCanonicalResultTitle(
withLinkedSectionIdOnly(occurrence.originalFields, occurrence.importFileId),
occurrence,
canonicalTitleByOccurrence.get(occurrence)
), occurrence
), occurrence),
...(() => { ...(() => {
const cveId = cveIdFromRuleId(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId); const cveId = cveIdFromRuleId(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId);
const enrichment = cveId ? enrichments.get(cveId) : undefined; const enrichment = cveId ? enrichments.get(cveId) : undefined;
@ -305,7 +383,10 @@ function projectTool(models: CanonicalAssessmentModel[]): ToolIdentity {
return { id: 'generic', name: 'Tabular Data' }; return { id: 'generic', name: 'Tabular Data' };
} }
export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): ImportedDataset | null { export function canonicalModelsToDataset(
models: CanonicalAssessmentModel[],
titleSourceModels: CanonicalAssessmentModel[] = models
): ImportedDataset | null {
if (!models.length) return null; if (!models.length) return null;
const project = models[0].project; const project = models[0].project;
const runs = models.flatMap(model => model.scanRuns); const runs = models.flatMap(model => model.scanRuns);
@ -336,7 +417,17 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
} }
} }
const enrichments = new Map(models.flatMap(model => model.cveEnrichments ?? []).map(record => [record.cveId, record])); const enrichments = new Map(models.flatMap(model => model.cveEnrichments ?? []).map(record => [record.cveId, record]));
const displayFields = displayFieldsByOccurrence(occurrences, enrichments); const canonicalTitleByOccurrence = new Map<FindingOccurrence, string>();
const preferredPingCastleTitles = preferredPingCastleReportTitles(titleSourceModels);
for (const model of models) {
const definitions = new Map(model.findingDefinitions.map(definition => [definition.id, definition]));
for (const occurrence of model.findingOccurrences) {
const title = preferredPingCastleTitleForOccurrence(model, fileById.get(occurrence.importFileId), occurrence, preferredPingCastleTitles)
?? definitions.get(occurrence.definitionId)?.title;
if (title) canonicalTitleByOccurrence.set(occurrence, title);
}
}
const displayFields = displayFieldsByOccurrence(occurrences, enrichments, canonicalTitleByOccurrence);
const tableSheets: DatasetSheet[] = []; const tableSheets: DatasetSheet[] = [];
const supportingSheets: DatasetSheet[] = []; const supportingSheets: DatasetSheet[] = [];

View File

@ -114,7 +114,7 @@ export async function loadCanonicalProjectWorkspace(
{ audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] } { audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] }
) )
: []; : [];
const scannerDataset = canonicalModelsToDataset(models); const scannerDataset = canonicalModelsToDataset(models, loadedModels);
if (scannerDataset?.securityScoring) { if (scannerDataset?.securityScoring) {
const currentIds = new Set(assessmentIds); const currentIds = new Set(assessmentIds);
const currentDates = models.map(model => Date.parse(model.assessment.effectiveAt ?? model.assessment.createdAt)).filter(Number.isFinite); const currentDates = models.map(model => Date.parse(model.assessment.effectiveAt ?? model.assessment.createdAt)).filter(Number.isFinite);

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { UnifiedResultFacetResult, UnifiedResultProjection, UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result'; import { resultSourceToolLabel, type UnifiedResultFacetResult, type UnifiedResultProjection, type UnifiedResultQuery, type UnifiedResultQueryResult } from '../../domain/unified-result';
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../domain/result-db-disclosure';
import { ResultsFilterPanel } from './ResultsFilterPanel'; import { ResultsFilterPanel } from './ResultsFilterPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState'; import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
@ -15,6 +16,7 @@ import { RegulatoryMappingBatchDialog, useRegulatoryMappingBatch } from './Regul
import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants'; import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants';
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels'; import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -175,7 +177,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
</div> </div>
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results"> {!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
<div className="results-browser-row results-browser-head" role="row"> <div className="results-browser-row results-browser-head" role="row">
<span>Title</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span><span>Review</span> <span>Title</span><span>Source Rule ID</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span><span>Review</span>
</div> </div>
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item} {queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
expanded={expandedId === item.resultProjectionId} expanded={expandedId === item.resultProjectionId}
@ -193,15 +195,17 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
</div>; </div>;
} }
export function ResultRow({ item, expanded, onToggle, onUpdated }: { export function ResultRow({ item: sourceItem, expanded, onToggle, onUpdated }: {
item: UnifiedResultProjection; item: UnifiedResultProjection;
expanded: boolean; expanded: boolean;
onToggle(): void; onToggle(): void;
onUpdated?(): Promise<void>; onUpdated?(): Promise<void>;
}) { }) {
const item = removeResultDbScopeIdentifiers(discloseResultDbProjection(sourceItem));
const environment = item.sourceKind === 'environment-observation'; const environment = item.sourceKind === 'environment-observation';
const policyObservation = item.sourceKind === 'policy-observation'; const policyObservation = item.sourceKind === 'policy-observation';
const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel); const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel);
const policyIdentity = displayPolicyIdentity(item);
const contextRecord = environment || policyObservation; const contextRecord = environment || policyObservation;
const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities' const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities'
&& /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined; && /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined;
@ -223,12 +227,13 @@ export function ResultRow({ item, expanded, onToggle, onUpdated }: {
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row"> return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}> <button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span> <span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
<span data-label="Source Rule ID" title={item.sourceRuleId}>{item.sourceRuleId ?? '–'}</span>
<span data-label="Severity">{contextRecord ? 'Context' : item.severityNormalized}</span> <span data-label="Severity">{contextRecord ? 'Context' : item.severityNormalized}</span>
<span data-label="Outcome" className={contextRecord ? '' : 'outcome-mark'} data-outcome={contextRecord ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : policyObservation ? item.policyIdentityStatus : item.sourceKind === 'policy-evaluation' && item.policyCustomerState === 'missing' && item.outcomeNormalized === 'informational' ? 'Not configured' : item.outcomeNormalized}</span> <span data-label="Outcome" className={contextRecord ? '' : 'outcome-mark'} data-outcome={contextRecord ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : policyObservation ? item.policyIdentityStatus : item.sourceKind === 'policy-evaluation' && item.policyCustomerState === 'missing' && item.outcomeNormalized === 'informational' ? 'Not configured' : item.outcomeNormalized}</span>
<span data-label="Result Type">{environment ? 'Environment' : policyObservation ? 'GPO Observation' : item.resultType}</span> <span data-label="Result Type">{environment ? 'Environment' : policyObservation ? 'GPO Observation' : item.resultType}</span>
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span> <span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span> <span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
<span data-label="Tool">{item.sourceTool ?? '–'}</span> <span data-label="Tool">{resultSourceToolLabel(item.sourceTool)}</span>
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span> <span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span> <span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
</button> </button>
@ -243,6 +248,12 @@ export function ResultRow({ item, expanded, onToggle, onUpdated }: {
</div>} </div>}
{item.description && <p>{item.description}</p>} {item.description && <p>{item.description}</p>}
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>} {item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
{policyComparison && <div className="results-environment-value">
<strong>Configured customer value</strong>
<p>{displayPolicyValue(item.policyActualValue)}</p>
<strong>Expected baseline value</strong>
<p>{displayPolicyValue(item.policyBaselineValue)}</p>
</div>}
<ul> <ul>
<li>Source: {item.sourceProfileId ?? '–'}@{item.sourceProfileVersion ?? '–'}</li> <li>Source: {item.sourceProfileId ?? '–'}@{item.sourceProfileVersion ?? '–'}</li>
<li>Processing: profile {item.sourceProfileVersion ?? '–'} · mapping {item.mappingRuleVersion ?? '–'} · normalization {item.normalizationRuleVersion ?? '–'} · presentation {item.presentationVersion ?? '–'}</li> <li>Processing: profile {item.sourceProfileVersion ?? '–'} · mapping {item.mappingRuleVersion ?? '–'} · normalization {item.normalizationRuleVersion ?? '–'} · presentation {item.presentationVersion ?? '–'}</li>
@ -260,18 +271,18 @@ export function ResultRow({ item, expanded, onToggle, onUpdated }: {
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li> <li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li> <li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
</> : policyComparison ? <> </> : policyComparison ? <>
{policyIdentity && <li>Technical setting ID: {policyIdentity}</li>}
<li>Baseline: {item.policyBaselineLabel}</li> <li>Baseline: {item.policyBaselineLabel}</li>
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li> <li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
<li>Actual: {displayEnvironmentValue(item.policyActualValue)}</li>
{item.policyBaselineVariantAssessment ? <li> {item.policyBaselineVariantAssessment ? <li>
{baselineVariantSummary(item.policyBaselineVariantAssessment)} {baselineVariantSummary(item.policyBaselineVariantAssessment)}
<p>No effective device state or GPO application is established by this backup comparison.</p> <p>No effective device state or GPO application is established by this backup comparison.</p>
<ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}> <ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}>
Setting reference: {variant.settingId} · Value comparison: {variant.comparator.relation} Setting reference: {variant.settingId} · Value comparison: {variant.comparator.relation}
<p>Expected: {displayEnvironmentValue(variant.comparator.baseline)}</p> <p className="policy-guidance-text">Expected: {displayPolicyValue(variant.comparator.baseline)}</p>
<p>Original baseline value: {displayEnvironmentValue(variant.baselineValues)}</p> <p className="policy-guidance-text">Original baseline value: {displayPolicyValue(variant.baselineValues)}</p>
</li>)}</ul> </li>)}</ul>
</li> : <li>Baseline value: {displayEnvironmentValue(item.policyBaselineValue)}</li>} </li> : null}
<li>Comparison: {item.policyComparisonRelation ?? '–'} · Customer state: {item.policyCustomerState ?? '–'}</li> <li>Comparison: {item.policyComparisonRelation ?? '–'} · Customer state: {item.policyCustomerState ?? '–'}</li>
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li> <li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
</> : <> </> : <>
@ -334,6 +345,15 @@ function displayEnvironmentValue(value: unknown): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function displayPolicyValue(value: unknown): string {
return value === undefined ? '–' : formatPolicyComparisonValue(JSON.stringify(value));
}
function displayPolicyIdentity(item: UnifiedResultProjection): string | undefined {
const identity = item.policyIdentityKey ?? item.sourceRuleId;
return identity && !/^gpo-policy-identity-[0-9a-f]+$/i.test(identity) ? identity : undefined;
}
function processingStatusLabel(value: string | undefined): string { function processingStatusLabel(value: string | undefined): string {
if (value === 'current') return 'Current'; if (value === 'current') return 'Current';
if (value === 'compatible-older') return 'Compatible older'; if (value === 'compatible-older') return 'Compatible older';

View File

@ -42,6 +42,7 @@ export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: R
function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions): FindingRecord[] { function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions): FindingRecord[] {
if (!sheet) return []; if (!sheet) return [];
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews); const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews); const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = contextToolId === 'group-policy' const facets = contextToolId === 'group-policy'
@ -53,8 +54,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
if (options.actionOnly && !options.reviews[record.id]?.include) return false; if (options.actionOnly && !options.reviews[record.id]?.include) return false;
if (!options.activeProfile.matchesStatus(record, options.statusFilter)) return false; if (!options.activeProfile.matchesStatus(record, options.statusFilter)) return false;
if (!matchesDynamicFacets(record, facets, options.facetFilters, options.reviews)) return false; if (!matchesDynamicFacets(record, facets, options.facetFilters, options.reviews)) return false;
if (!options.search) return true; if (!normalizedSearch) return true;
return record.fields.some(field => plainTextValue(field.value).toLocaleLowerCase('en-US').includes(options.search)); return [record.title, ...record.fields.map(field => plainTextValue(field.value))]
.some(value => value.toLocaleLowerCase('en-US').includes(normalizedSearch));
}); });
// Ohne ausdrueckliche numerische Sortierung stehen die kritischsten Funde // Ohne ausdrueckliche numerische Sortierung stehen die kritischsten Funde
// oben - ueber die kanonische Severity-Skala und damit fuer jeden Bericht // oben - ueber die kanonische Severity-Skala und damit fuer jeden Bericht

View File

@ -75,13 +75,34 @@ function fieldValue(fields: Array<{ role: CanonicalFieldRole; value: string }>,
function toolFor(entry: ImportPreviewEntry): ToolIdentity { function toolFor(entry: ImportPreviewEntry): ToolIdentity {
const detectedVersion = entry.dataset?.extraction?.environmentObservations const detectedVersion = entry.dataset?.extraction?.environmentObservations
?.find(item => item.property === 'report.engine_version')?.normalizedValue; ?.find(item => item.property === 'report.engine_version')?.normalizedValue;
const properties = entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? [];
const purpleKnightScope = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name')
? 'entra'
: properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name')
? 'ad'
: undefined;
const id = entry.toolId && entry.toolId !== 'unknown' ? entry.toolId : entry.dataset?.profileId ?? 'generic';
const detectedName = entry.toolName && entry.toolName !== 'Unknown' ? entry.toolName : entry.dataset?.scanTool ?? 'Generic';
return { return {
id: entry.toolId && entry.toolId !== 'unknown' ? entry.toolId : entry.dataset?.profileId ?? 'generic', id,
name: entry.toolName && entry.toolName !== 'Unknown' ? entry.toolName : entry.dataset?.scanTool ?? 'Generic', name: id === 'purple-knight' && purpleKnightScope
? purpleKnightScope === 'entra' ? 'Purple Knight for Entra ID' : 'Purple Knight for Active Directory'
: detectedName,
...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {}) ...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {})
}; };
} }
function purpleKnightRunTool(entries: ImportPreviewEntry[], fallback: ToolIdentity): ToolIdentity {
if (fallback.id !== 'purple-knight') return fallback;
const properties = entries.flatMap(entry => entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? []);
const entra = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name');
const ad = properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name');
if (entra && ad) return { ...fallback, name: 'Purple Knight for Active Directory and Entra ID' };
if (entra) return { ...fallback, name: 'Purple Knight for Entra ID' };
if (ad) return { ...fallback, name: 'Purple Knight for Active Directory' };
return fallback;
}
export function createCanonicalImportBatch( export function createCanonicalImportBatch(
batchId: string, batchId: string,
entries: ImportPreviewEntry[], entries: ImportPreviewEntry[],
@ -110,11 +131,12 @@ export function createCanonicalImportBatch(
const recognizedTools = new Map(sourceTools const recognizedTools = new Map(sourceTools
.filter(tool => tool.id !== 'generic' && tool.id !== 'unknown') .filter(tool => tool.id !== 'generic' && tool.id !== 'unknown')
.map(tool => [tool.id, tool])); .map(tool => [tool.id, tool]));
const runTool = recognizedTools.size === 1 const detectedRunTool = recognizedTools.size === 1
? [...recognizedTools.values()][0] ? [...recognizedTools.values()][0]
: recognizedTools.size > 1 : recognizedTools.size > 1
? { id: 'multiple', name: 'Multiple Tools' } ? { id: 'multiple', name: 'Multiple Tools' }
: { id: 'generic', name: 'Tabular Data' }; : { id: 'generic', name: 'Tabular Data' };
const runTool = purpleKnightRunTool(entries, detectedRunTool);
const projectName = `${runTool.name} · ${entries.length} Datei${entries.length === 1 ? '' : 'en'}`; const projectName = `${runTool.name} · ${entries.length} Datei${entries.length === 1 ? '' : 'en'}`;
const importFiles: ImportFileRecord[] = []; const importFiles: ImportFileRecord[] = [];
const sourceTables: SourceTableRecord[] = []; const sourceTables: SourceTableRecord[] = [];

View File

@ -25,7 +25,7 @@ import { clean, normalizeNumericValue } from '../shared/text';
import { genericTableSourceExtractor } from './generic-table-source-extractor'; import { genericTableSourceExtractor } from './generic-table-source-extractor';
/** Abschnitt "AD category score: <Kategorie>" der Zusammenfassung. */ /** Abschnitt "AD category score: <Kategorie>" der Zusammenfassung. */
const CATEGORY_SCORE = /^(ad|aad|entra)\s+category\s+score:\s*(.+)$/i; const CATEGORY_SCORE = /^(ad|aad|entra(?:\s*id)?)\s+category\s+score:\s*(.+)$/i;
/** Kennzahl mit vorangestelltem Erhebungsbereich, z. B. "AD: Passed". */ /** Kennzahl mit vorangestelltem Erhebungsbereich, z. B. "AD: Passed". */
const SCOPED_METRIC = /^(ad|aad|entra(?:\s*id)?):\s*(.+)$/i; const SCOPED_METRIC = /^(ad|aad|entra(?:\s*id)?):\s*(.+)$/i;
/** Kopfzeile der Zusammenfassung: "Tool edition" und die eingesetzte Edition. */ /** Kopfzeile der Zusammenfassung: "Tool edition" und die eingesetzte Edition. */
@ -37,11 +37,12 @@ const TOOL_EDITION = /^tool\s+edition$/i;
* Tabelle mit einem aehnlich benannten Feld nicht faelschlich hier landet. * Tabelle mit einem aehnlich benannten Feld nicht faelschlich hier landet.
*/ */
const SUMMARY_KEYS = [ const SUMMARY_KEYS = [
/^ad:\s*security\s+posture\s+score$/i, /^(?:ad|aad|entra(?:\s*id)?):\s*security\s+posture\s+score$/i,
/^ad:\s*ioes?\s+found$/i, /^(?:ad|aad|entra(?:\s*id)?):\s*ioes?\s+found$/i,
/^ad:\s*number\s+of\s+domains$/i, /^ad:\s*number\s+of\s+domains$/i,
/^ad:\s*forest\s+name$/i, /^ad:\s*forest\s+name$/i,
/^ad\s+category\s+score:/i, /^entra(?:\s*id)?:\s*tenant$/i,
/^(?:ad|aad|entra(?:\s*id)?)\s+category\s+score:/i,
/^selected\s+indicators$/i /^selected\s+indicators$/i
]; ];
@ -89,6 +90,9 @@ const SHARED_PROPERTIES: Array<[RegExp, MetricMapping]> = [
}], }],
[/^ad:\s*number\s+of\s+domains$/i, { [/^ad:\s*number\s+of\s+domains$/i, {
property: 'domain.count', label: 'Number of domains', valueType: 'number', subject: ENVIRONMENT property: 'domain.count', label: 'Number of domains', valueType: 'number', subject: ENVIRONMENT
}],
[/^entra(?:\s*id)?:\s*tenant$/i, {
property: 'entra.tenant_name', label: 'Entra tenant', valueType: 'string', subject: { type: 'entra-tenant' }
}] }]
]; ];
@ -135,7 +139,9 @@ export function purpleKnightMetricObservation(key: string, value: JsonValue, sou
: { normalizedValue: value, valueType: value !== null && typeof value === 'object' ? 'object' : mapping.valueType }; : { normalizedValue: value, valueType: value !== null && typeof value === 'object' ? 'object' : mapping.valueType };
return { return {
version: 1, version: 1,
subject: mapping.subject, subject: mapping.property === 'entra.tenant_name' && typeof value === 'string' && value.trim()
? { type: 'entra-tenant', identity: value.trim(), label: value.trim() }
: mapping.subject,
property: mapping.property, property: mapping.property,
label: mapping.label, label: mapping.label,
originalValue: value, originalValue: value,
@ -176,7 +182,7 @@ function summaryTable(document: DecodedSourceDocument): ImportTable | undefined
export const purpleKnightSummarySourceExtractor: SourceExtractor = { export const purpleKnightSummarySourceExtractor: SourceExtractor = {
id: 'purple-knight-summary-source-extractor', id: 'purple-knight-summary-source-extractor',
version: '1.1.0', version: '1.2.0',
detect(document) { detect(document) {
return summaryTable(document) return summaryTable(document)
? { score: 1, toolId: 'purple-knight', evidence: ['PurpleKnight summary metrics recognized.'] } ? { score: 1, toolId: 'purple-knight', evidence: ['PurpleKnight summary metrics recognized.'] }

View File

@ -69,6 +69,7 @@ import {
type UnifiedResultQuery, type UnifiedResultQuery,
type UnifiedResultQueryResult type UnifiedResultQueryResult
} from '../domain/unified-result'; } from '../domain/unified-result';
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../domain/result-db-disclosure';
import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results'; import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results';
import type { import type {
AssessorRepository, AssessorRepository,
@ -797,11 +798,15 @@ export class LocalAssessorRepository implements AssessorRepository {
const policyConsistency = audience === 'result-db' const policyConsistency = audience === 'result-db'
? createPolicyConsistencyUnifiedResults(currentPolicyObservations) ? createPolicyConsistencyUnifiedResults(currentPolicyObservations)
: []; : [];
return [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; const results = [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
return audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection);
} }
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> { async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
return clone(queryUnifiedResultItems(this.unifiedResults(query.audience), query)); const result = queryUnifiedResultItems(this.unifiedResults(query.audience), query);
return clone(query.audience === 'internal-analysis'
? result
: { ...result, items: result.items.map(removeResultDbScopeIdentifiers) });
} }
async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise<UnifiedResultFacetResult> { async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise<UnifiedResultFacetResult> {
@ -810,7 +815,7 @@ export class LocalAssessorRepository implements AssessorRepository {
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> { async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId); const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId);
return result ? clone(result) : null; return result ? clone(removeResultDbScopeIdentifiers(result)) : null;
} }
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> { async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {

View File

@ -0,0 +1,41 @@
import type { FindingRecord, RecordField } from '../../domain/finding';
const normalizedLabel = (label: string) => label.trim().toLocaleLowerCase('en-US').replace(/[\s_-]+/g, ' ');
function field(record: FindingRecord, label: string): RecordField | undefined {
return record.fields.find(item => normalizedLabel(item.label) === label);
}
/** Maester carries an ID-prefixed Name beside the actual Title. Work uses the Title. */
export function maesterFindingTitle(record: FindingRecord, fallback: string): string {
return field(record, 'title')?.value.trim() || fallback;
}
export function maesterTestResultText(status: string, detail = ''): string {
if (detail.trim()) return detail.trim();
if (/^pass(?:ed)?$/i.test(status)) return 'Test passed.';
if (/^fail(?:ed)?$/i.test(status)) return 'Test failed.';
if (/^investigate$/i.test(status)) return 'Test requires investigation.';
if (/^(?:skip(?:ped)?|not\s*run)$/i.test(status)) return 'Test skipped.';
return status.trim();
}
export function maesterTagText(value: string): string {
try {
const parsed = JSON.parse(value) as unknown;
if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) {
return parsed.map(item => item.trim()).filter(Boolean).join(' · ');
}
} catch { /* Preserve the unchanged source text when it is not JSON. */ }
return value.trim();
}
export function maesterExecutionErrorText(value: string): string {
try {
const parsed = JSON.parse(value) as unknown;
if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) {
return parsed.map(item => item.trim()).filter(Boolean).join('\n');
}
} catch { /* Preserve the unchanged source text when it is not JSON. */ }
return value.trim();
}

View File

@ -0,0 +1,83 @@
import type { CanonicalAssessmentModel, FindingOccurrence, ImportFileRecord } from '../../domain/canonical-model';
interface TitleCandidate {
title: string;
confidence: number;
}
function reportStem(fileName: string): string {
return fileName.split(/[\\/]/).at(-1)?.replace(/\.(?:html?|xml)$/i, '')
.replace(/[_-]auditor$/i, '').toLocaleLowerCase('en-US') ?? '';
}
function reportDate(model: CanonicalAssessmentModel, file: ImportFileRecord): string {
const value = model.assessment.effectiveAt ?? file.lastModifiedAt ?? file.importedAt;
return /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0] ?? '';
}
function reportRuleKey(
model: CanonicalAssessmentModel,
file: ImportFileRecord | undefined,
occurrence: FindingOccurrence
): string | undefined {
const ruleId = (occurrence.sourceIdentity.originalRuleId
?? occurrence.sourceIdentity.canonicalRuleId)?.trim().toLocaleLowerCase('en-US');
if (occurrence.sourceIdentity.tool.id !== 'pingcastle' || !file || !ruleId) return undefined;
const stem = reportStem(file.fileName);
const date = reportDate(model, file);
return stem && date ? [model.project.id, date, stem, ruleId].join('\0') : undefined;
}
function titleConfidence(file: ImportFileRecord, occurrence: FindingOccurrence): number {
const format = file.format.toLocaleLowerCase('en-US');
// XML's Rationale and HTML's distinct card title describe the matched
// instance. A legacy HTML projection containing only the h3 check text is
// still useful, but is not the preferred heading for that same report/rule.
if (format === 'xml') {
const title = occurrence.sourceIdentity.originalTitle?.trim();
const ruleId = occurrence.sourceIdentity.originalRuleId?.trim();
return title && title !== ruleId ? 2 : 1;
}
const checkTitle = occurrence.originalFields.find(field => field.label === 'Check title')?.value;
return format === 'html' && typeof checkTitle === 'string'
&& checkTitle.trim() !== occurrence.sourceIdentity.originalTitle?.trim() ? 2 : 1;
}
/**
* Reconcile only exact PingCastle report/rule peers. Separate runs stay separate
* occurrences; an ambiguous set of equally authoritative titles is not merged.
*/
export function preferredPingCastleReportTitles(models: CanonicalAssessmentModel[]): ReadonlyMap<string, string> {
const candidates = new Map<string, TitleCandidate[]>();
for (const model of models) {
const definitions = new Map(model.findingDefinitions.map(definition => [definition.id, definition]));
const files = new Map(model.importFiles.map(file => [file.id, file]));
for (const occurrence of model.findingOccurrences) {
const file = files.get(occurrence.importFileId);
const key = reportRuleKey(model, file, occurrence);
const title = definitions.get(occurrence.definitionId)?.title.trim();
if (!key || !title || !file) continue;
const group = candidates.get(key) ?? [];
group.push({ title, confidence: titleConfidence(file, occurrence) });
candidates.set(key, group);
}
}
const preferred = new Map<string, string>();
for (const [key, group] of candidates) {
const confidence = Math.max(...group.map(candidate => candidate.confidence));
const best = [...new Set(group.filter(candidate => candidate.confidence === confidence)
.map(candidate => candidate.title))];
if (best.length === 1) preferred.set(key, best[0]);
}
return preferred;
}
export function preferredPingCastleTitleForOccurrence(
model: CanonicalAssessmentModel,
file: ImportFileRecord | undefined,
occurrence: FindingOccurrence,
titles: ReadonlyMap<string, string>
): string | undefined {
const key = reportRuleKey(model, file, occurrence);
return key ? titles.get(key) : undefined;
}

View File

@ -125,6 +125,7 @@
"SeTakeOwnershipPrivilege" "SeTakeOwnershipPrivilege"
], ],
"maesterIds": [ "maesterIds": [
"CISA.MS.EXO.17.3",
"MT.1001", "MT.1001",
"MT.1002", "MT.1002",
"MT.1003", "MT.1003",
@ -290,54 +291,188 @@
"pingcastleIds": [ "pingcastleIds": [
"A-AdminSDHolder", "A-AdminSDHolder",
"A-AnonymousAuthorizedGPO", "A-AnonymousAuthorizedGPO",
"A-AuditDC",
"A-AuditPowershell",
"A-BackupMetadata",
"A-CertEnrollChannelBinding",
"A-CertEnrollHttp",
"A-CertROCA",
"A-CertTempAgent", "A-CertTempAgent",
"A-CertTempAnyone",
"A-CertTempAnyPurpose", "A-CertTempAnyPurpose",
"A-CertTempCustomSubject",
"A-CertTempNoSecurity",
"A-CertWeakDSA",
"A-CertWeakRsaComponent",
"A-DC-Coerce",
"A-DC-Spooler",
"A-DC-WebClient",
"A-DCLdapsChannelBinding",
"A-DCLdapSign",
"A-DCLdapsProtocol",
"A-DCLdapsProtocolAdvanced",
"A-DCRefuseComputerPwdChange",
"A-DnsZoneAUCreateChild",
"A-DnsZoneTransfert",
"A-DnsZoneUpdate1",
"A-DnsZoneUpdate2",
"A-DsHeuristicsAllowAnonNSPI", "A-DsHeuristicsAllowAnonNSPI",
"A-DsHeuristicsAnonymous", "A-DsHeuristicsAnonymous",
"A-DsHeuristicsDoNotVerifyUniqueness",
"A-DsHeuristicsLDAPSecurity",
"A-Guest", "A-Guest",
"A-HardenedPaths", "A-HardenedPaths",
"A-Krbtgt", "A-Krbtgt",
"A-LAPS-Joined-Computers",
"A-LAPS-Not-Installed",
"A-LDAPSigningDisabled", "A-LDAPSigningDisabled",
"A-LimitBlankPasswordUse", "A-LimitBlankPasswordUse",
"A-LMHashAuthorized",
"A-MD2IntermediateCert",
"A-MD2RootCert", "A-MD2RootCert",
"A-MD4IntermediateCert",
"A-MD4RootCert", "A-MD4RootCert",
"A-MD5IntermediateCert", "A-MD5IntermediateCert",
"A-MD5RootCert",
"A-MembershipEveryone",
"A-MinPwdLen", "A-MinPwdLen",
"A-NoGPOLLMNR",
"A-NoNetSessionHardening",
"A-NoServicePolicy", "A-NoServicePolicy",
"A-NotEnoughDC",
"A-NTFRSOnSysvol",
"A-NullSession",
"A-PreWin2000Anonymous",
"A-PreWin2000AuthenticatedUsers",
"A-PreWin2000Other",
"A-ProtectedUsers",
"A-PwdGPO", "A-PwdGPO",
"A-ReversiblePwd",
"A-RootDseAnonBinding",
"A-SHA0IntermediateCert", "A-SHA0IntermediateCert",
"A-SHA0RootCert",
"A-SHA1IntermediateCert", "A-SHA1IntermediateCert",
"A-SHA1RootCert",
"A-SmartCardPwdRotation",
"A-SmartCardRequired",
"A-SMB2SignatureNotEnabled",
"A-SMB2SignatureNotRequired", "A-SMB2SignatureNotRequired",
"A-UnixPwd",
"A-WeakRSARootCert",
"A-WeakRSARootCert2",
"A-WSUS-SslProtocol", "A-WSUS-SslProtocol",
"P-AdminEmailOn",
"P-AdminLogin",
"P-AdminNum",
"P-AdminPwdTooOld",
"P-ControlPathIndirectEveryone", "P-ControlPathIndirectEveryone",
"P-ControlPathIndirectMany", "P-ControlPathIndirectMany",
"P-DNSDelegation", "P-DangerousExtendedRight",
"P-DCOwner",
"P-Delegated",
"P-DelegationDCa2d2",
"P-DelegationDCsourcedeleg",
"P-DelegationDCt2a4d",
"P-DelegationEveryone", "P-DelegationEveryone",
"P-DelegationFileDeployed",
"P-DelegationGPOData",
"P-DelegationKeyAdmin", "P-DelegationKeyAdmin",
"P-DelegationLoginScript", "P-DelegationLoginScript",
"P-DisplaySpecifier",
"P-DNSAdmin",
"P-DNSDelegation",
"P-DsHeuristicsAdminSDExMask",
"P-DsHeuristicsDoListObject",
"P-ExchangeAdminSDHolder",
"P-ExchangePrivEsc", "P-ExchangePrivEsc",
"P-Inactive",
"P-Kerberoasting",
"P-LoginDCEveryone",
"P-LogonDenied",
"P-OperatorsEmpty",
"P-PrivilegeEveryone",
"P-ProtectedUsers",
"P-RecoveryModeUnprotected",
"P-RecycleBin",
"P-RODCAdminRevealed", "P-RODCAdminRevealed",
"P-RODCAllowedGroup",
"P-RODCDeniedGroup",
"P-RODCKrbtgtOrphan",
"P-RODCNeverReveal", "P-RODCNeverReveal",
"P-RODCRevealOnDemand", "P-RODCRevealOnDemand",
"P-RODCSYSVOLWrite", "P-RODCSYSVOLWrite",
"P-SchemaAdmin",
"P-ServiceDomainAdmin",
"P-TrustedCredManAccessPrivilege",
"P-UnconstrainedDelegation",
"P-UnkownDelegation",
"P-UnprotectedOU",
"S-ADRegistration",
"S-ADRegistrationSchema",
"S-AesNotEnabled",
"S-C-Inactive", "S-C-Inactive",
"S-C-PrimaryGroup", "S-C-PrimaryGroup",
"S-C-Reversible",
"S-DC-2000",
"S-DC-2003",
"S-DC-2008",
"S-DC-2012",
"S-DC-Inactive",
"S-DC-NotUpdated", "S-DC-NotUpdated",
"S-DC-SubnetMissing", "S-DC-SubnetMissing",
"S-DCRegistration",
"S-DefaultOUChanged",
"S-DefenderASR", "S-DefenderASR",
"S-DesEnabled",
"S-Duplicate",
"S-FirewallScript", "S-FirewallScript",
"S-FolderOptions", "S-FolderOptions",
"S-FunctionalLevel1",
"S-FunctionalLevel3",
"S-FunctionalLevel4",
"S-Inactive",
"S-JavaSchema",
"S-KerberosArmoring",
"S-KerberosArmoringDC",
"S-NoPreAuth",
"S-NoPreAuthAdmin",
"S-OldNtlm",
"S-OS-2000",
"S-OS-2003", "S-OS-2003",
"S-OS-2008", "S-OS-2008",
"S-OS-2012", "S-OS-2012",
"S-OS-NT", "S-OS-NT",
"S-OS-Vista",
"S-OS-W10",
"S-OS-Win7",
"S-OS-Win8",
"S-OS-XP",
"S-PrimaryGroup",
"S-PwdLastSet-45",
"S-PwdLastSet-90",
"S-PwdLastSet-Cluster",
"S-PwdLastSet-DC",
"S-PwdNeverExpires",
"S-PwdNotRequired",
"S-Reversible",
"S-SIDHistory",
"S-SMB-v1",
"S-TerminalServicesGPO", "S-TerminalServicesGPO",
"S-Vuln-MS14-068",
"S-Vuln-MS17_010", "S-Vuln-MS17_010",
"S-WSUS-HTTP", "S-WSUS-HTTP",
"S-WSUS-NoPinning",
"S-WSUS-UserProxy", "S-WSUS-UserProxy",
"T-AlgsAES",
"T-AzureADSSO",
"T-Downlevel",
"T-FileDeployedOutOfDomain", "T-FileDeployedOutOfDomain",
"T-Inactive", "T-Inactive",
"T-ScriptOutOfDomain", "T-ScriptOutOfDomain",
"T-SIDFiltering",
"T-SIDHistoryDangerous",
"T-SIDHistorySameDomain",
"T-SIDHistoryUnknownDomain",
"T-TGTDelegation" "T-TGTDelegation"
], ],
"asrIds": [ "asrIds": [

View File

@ -20,7 +20,8 @@ const topics: Array<[RegExp, string]> = [
[/\bPIM\b|\bPrivileged Identity Management\b/i, 'Entra Privileged Identity Management'], [/\bPIM\b|\bPrivileged Identity Management\b/i, 'Entra Privileged Identity Management'],
[/\bIntune\b/i, 'Microsoft Intune device configuration'], [/\bExchange Online\b/i, 'Exchange Online security'], [/\bIntune\b/i, 'Microsoft Intune device configuration'], [/\bExchange Online\b/i, 'Exchange Online security'],
[/\bDefender\b/i, 'Microsoft Defender protection'], [/\bAppLocker\b/i, 'AppLocker application control'], [/\bDefender\b/i, 'Microsoft Defender protection'], [/\bAppLocker\b/i, 'AppLocker application control'],
[/\bPowerShell\b/i, 'PowerShell security logging'], [/\baudit\b/i, 'Windows audit policy'], [/\baudit logs?.*(?:retention|maintained)|(?:retention|maintained).*audit logs?/i, 'Microsoft Purview audit log retention'],
[/\bPowerShell\b/i, 'PowerShell security logging'], [/\bWindows audit\b|\baudit policy\b/i, 'Windows audit policy'],
[/\bpassword\b/i, 'Password policy'], [/\bfirewall\b/i, 'Firewall security policy'], [/\bpassword\b/i, 'Password policy'], [/\bfirewall\b/i, 'Firewall security policy'],
[/\bOpenSSH\b|\bsshd\b/i, 'OpenSSH server hardening'], [/\bsudo\b/i, 'Linux sudo privilege configuration'], [/\bOpenSSH\b|\bsshd\b/i, 'OpenSSH server hardening'], [/\bsudo\b/i, 'Linux sudo privilege configuration'],
[/\bauditd\b/i, 'Linux auditd configuration'], [/\bSELinux\b/i, 'SELinux mandatory access control'], [/\bauditd\b/i, 'Linux auditd configuration'], [/\bSELinux\b/i, 'SELinux mandatory access control'],

View File

@ -67,11 +67,14 @@ function selectedTarget(record: FindingRecord): string {
/** Only catalog-owned identifiers are returned, including for dynamic test instances. */ /** Only catalog-owned identifiers are returned, including for dynamic test instances. */
function ruleContext(record: FindingRecord, tool: string): string | undefined { function ruleContext(record: FindingRecord, tool: string): string | undefined {
const ids = tool === 'maester' ? catalog.maesterIds : tool === 'pingcastle' ? catalog.pingcastleIds : []; const ids = tool === 'maester' ? catalog.maesterIds : tool === 'pingcastle' ? catalog.pingcastleIds : [];
const raw = field(record, ['rule id', 'rule-id', 'test id', 'test-id', 'id']); const raw = field(record, ['source rule id', 'rule id', 'rule-id', 'test id', 'test-id', 'id']);
const exact = ids.find(id => fold(id) === fold(raw) || fold(id) === fold(record.title)); const exact = ids.find(id => fold(id) === fold(raw) || fold(id) === fold(record.title));
const candidates = exact ? [exact] : ids.filter(id => new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s*[-:·]|\\.[0-9]+(?:[: .]|$))`, 'i').test(record.title)); const candidates = exact ? [exact] : ids.filter(id => new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s*[-:·]|\\.[0-9]+(?:[: .]|$))`, 'i').test(record.title));
if (candidates.length !== 1) return undefined; if (candidates.length !== 1) return undefined;
return `Public rule ID: ${candidates[0]}. Rule catalog: ${tool === 'maester' ? catalog.sources.maester : catalog.sources.pingcastle}. Retrieve this exact rule, its rationale and remediation; do not substitute another rule.`; const ruleSource = tool === 'maester'
? `https://maester.dev/docs/tests/${encodeURIComponent(candidates[0])}/`
: catalog.sources.pingcastle;
return `Public rule ID: ${candidates[0]}. Rule source: ${ruleSource}. Retrieve this exact rule, its rationale and remediation; do not substitute another rule.`;
} }
export function findingWebSearch(record: FindingRecord, toolId?: string): PublicSearch { export function findingWebSearch(record: FindingRecord, toolId?: string): PublicSearch {

View File

@ -8,11 +8,11 @@ export const purpleKnightDefinition: ToolProfileDefinition = {
securityScoring: { securityScoring: {
version: '1.0.0', version: '1.0.0',
native: { native: {
kind: 'mapped', properties: ['purple_knight.ad.security_posture_score'], kind: 'mapped', properties: ['purple_knight.ad.security_posture_score', 'purple_knight.entra.security_posture_score'],
scope: 'overall', scale: '0-100', direction: 'higher-is-better', scope: 'overall', scale: '0-100', direction: 'higher-is-better',
incompatibleProperties: ['purple_knight.entra.security_posture_score', 'purple_knight.aad.security_posture_score', 'purple_knight.okta.security_posture_score'] incompatibleProperties: ['purple_knight.okta.security_posture_score']
}, },
targets: ['forest.name'], resultPopulation: 'complete' targets: ['forest.name', 'entra.tenant_name'], resultPopulation: 'complete'
}, },
priorityBasis: { priorityBasis: {
version: '1.0.0', version: '1.0.0',
@ -44,11 +44,12 @@ export const purpleKnightDefinition: ToolProfileDefinition = {
// gegenueber 1.1.0 unveraendert, deshalb bleibt der Kompatibilitaetsschluessel // gegenueber 1.1.0 unveraendert, deshalb bleibt der Kompatibilitaetsschluessel
// bestehen; ein erneuter Import derselben Dateien ergaenzt den Kontext. // bestehen; ein erneuter Import derselben Dateien ergaenzt den Kontext.
// 1.3.0: Full workbook findings, HTML context/integrity and exact source values. // 1.3.0: Full workbook findings, HTML context/integrity and exact source values.
version: '1.3.0', // 1.4.0: Entra-ID summaries, tenant context and distinct AD/Entra scan labels.
version: '1.4.0',
processing: { processing: {
mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0',
compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible',
reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'] reimportRequired: true, affectedDimensions: ['environment-context', 'scoring']
}, },
sourceSignals: [ sourceSignals: [
{ scope: 'file-name', pattern: /^Security_Assessment_Report_.+_\(Indicators_results\)\.csv$/i, contribution: 0.25, message: 'Native PurpleKnight indicator CSV naming structure.' }, { scope: 'file-name', pattern: /^Security_Assessment_Report_.+_\(Indicators_results\)\.csv$/i, contribution: 0.25, message: 'Native PurpleKnight indicator CSV naming structure.' },
@ -64,7 +65,9 @@ export const purpleKnightDefinition: ToolProfileDefinition = {
// diese beiden Kennzahlmarken lief sie als generische Tabelle durch und // diese beiden Kennzahlmarken lief sie als generische Tabelle durch und
// ihre Kennzahlen trugen eine falsche Herkunft. // ihre Kennzahlen trugen eine falsche Herkunft.
{ scope: 'raw-text', pattern: /(?:^|[\r\n"])AD:\s*security posture score/i, contribution: 0.6, message: 'Die Datei nennt den Purple-Knight-Posture-Score.' }, { scope: 'raw-text', pattern: /(?:^|[\r\n"])AD:\s*security posture score/i, contribution: 0.6, message: 'Die Datei nennt den Purple-Knight-Posture-Score.' },
{ scope: 'raw-text', pattern: /(?:^|[\r\n"])AD category score:/i, contribution: 0.4, message: 'Die Datei nennt Purple-Knight-Kategoriewerte.' } { scope: 'raw-text', pattern: /(?:^|[\r\n"])AD category score:/i, contribution: 0.4, message: 'Die Datei nennt Purple-Knight-Kategoriewerte.' },
{ scope: 'raw-text', pattern: /(?:^|[\r\n"])Entra(?:\s+ID)?:\s*security posture score/i, contribution: 0.6, message: 'Die Datei nennt den Purple-Knight-Entra-Posture-Score.' },
{ scope: 'raw-text', pattern: /(?:^|[\r\n"])Entra(?:\s+ID)? category score:/i, contribution: 0.4, message: 'Die Datei nennt Purple-Knight-Entra-Kategoriewerte.' }
], ],
tableRules: { tableRules: {
preferredNames: [ preferredNames: [

View File

@ -19,7 +19,13 @@ function scoreObservation(item: EnvironmentObservation): EnvironmentObservation
export function securityToolId(value: string): string { export function securityToolId(value: string): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalized = value.toLowerCase().replace(/[^a-z0-9]/g, '');
return ({ purpleknight: 'purple-knight', pingcastle: 'pingcastle', ciscat: 'cis-cat', ciscatpro: 'cis-cat', maester: 'maester', gpos: 'group-policy', gpoanalysis: 'group-policy', grouppolicy: 'group-policy', defenderforiotvulnerabilities: 'defender-for-iot', microsoftdefenderforiot: 'defender-for-iot' } as Record<string, string>)[normalized] ?? value.toLowerCase(); return ({
purpleknight: 'purple-knight', purpleknightforactivedirectory: 'purple-knight',
purpleknightforentraid: 'purple-knight', purpleknightforactivedirectoryandentraid: 'purple-knight',
pingcastle: 'pingcastle', ciscat: 'cis-cat', ciscatpro: 'cis-cat', maester: 'maester',
gpos: 'group-policy', gpoanalysis: 'group-policy', grouppolicy: 'group-policy',
defenderforiotvulnerabilities: 'defender-for-iot', microsoftdefenderforiot: 'defender-for-iot'
} as Record<string, string>)[normalized] ?? value.toLowerCase();
} }
function valueOf(item: EnvironmentObservation): JsonValue { function valueOf(item: EnvironmentObservation): JsonValue {

View File

@ -388,10 +388,11 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-command-center .client-gpo-readiness { grid-column: 1 / -1; min-width: 0; margin-block: var(--space-3); } .client-command-center .client-gpo-readiness { grid-column: 1 / -1; min-width: 0; margin-block: var(--space-3); }
.client-command-center .client-gpo-readiness p { font-size: var(--type-body-size); } .client-command-center .client-gpo-readiness p { font-size: var(--type-body-size); }
.client-command-center .client-os-inventory { min-width: 0; } .client-command-center .client-os-inventory { min-width: 0; }
.client-command-center .client-os-inventory > header { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-2); } .client-command-center .client-os-inventory > header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-2); }
.client-command-center .client-os-inventory > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; } .client-command-center .client-os-inventory > header h3 { display: inline-flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--client-info-text); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; }
.client-command-center .client-os-inventory > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); } .client-command-center .client-os-inventory > header h3 .client-glyph { width: var(--space-5); height: var(--space-5); color: var(--client-info-muted); }
.client-command-center .client-os-inventory header p { margin: 0; font-size: var(--type-body-size); color: var(--client-info-muted); } .client-command-center .client-os-inventory header p { margin: 0; font-size: var(--type-body-size); color: var(--client-info-muted); }
.client-os-results-button { flex: 0 0 auto; margin-inline-start: auto; }
.client-command-center .client-os-groups { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--space-5); } .client-command-center .client-os-groups { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--space-5); }
.client-command-center .client-os-group { min-width: 0; } .client-command-center .client-os-group { min-width: 0; }
.client-command-center .client-os-group h4 { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); margin: 0 0 var(--space-1); color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; } .client-command-center .client-os-group h4 { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); margin: 0 0 var(--space-1); color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; }
@ -403,6 +404,29 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-command-center .client-os-group .client-os-source { width: 1%; padding-inline-start: var(--space-3); color: var(--client-info-muted); font-size: var(--type-table-body-size); white-space: nowrap; } .client-command-center .client-os-group .client-os-source { width: 1%; padding-inline-start: var(--space-3); color: var(--client-info-muted); font-size: var(--type-table-body-size); white-space: nowrap; }
.client-command-center .client-os-empty { color: var(--client-info-muted); font-size: var(--type-body-size); } .client-command-center .client-os-empty { color: var(--client-info-muted); font-size: var(--type-body-size); }
/* Nicht-modales Datenfenster: dieselbe flache Fenstergrammatik, Drag-/Resize-
Geometrie und Maximierungslogik wie Results, ohne dessen Finding-spezifische
Titelleisten- und Clustersemantik zu duplizieren. */
.floating-data-window { position: fixed; z-index: 2600; display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 520px; min-height: 280px; border: var(--border-hair) solid var(--line-strong); border-radius: var(--legacy-radius-sm); color: var(--text); background: var(--bg); box-shadow: var(--shadow-overlay); overflow: visible; }
.floating-data-window-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 38px; padding-inline-start: var(--space-4); border-bottom: var(--border-hair) solid var(--line); color: var(--heading); background: var(--panel-raised); cursor: move; touch-action: none; user-select: none; }
.floating-data-window.is-maximized .floating-data-window-head { cursor: default; }
.floating-data-window-head h2 { min-width: 0; margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); line-height: 1.35; }
.floating-data-window-actions { display: flex; align-self: stretch; }
.floating-data-window-actions button { display: grid; place-items: center; width: 38px; padding: 0; border: 0; border-left: var(--border-hair) solid var(--line); border-radius: 0; color: var(--heading); background: transparent; cursor: pointer; }
.floating-data-window-actions button:hover { background: var(--panel-hover); }
.floating-data-window-actions button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: -2px; }
.floating-data-window-body { min-width: 0; min-height: 0; padding: var(--panel-inset); overflow: auto; scrollbar-color: var(--border-strong) var(--surface-input); scrollbar-gutter: stable; scrollbar-width: thin; }
.floating-data-window .client-os-groups { grid-template-columns: max-content; width: max-content; max-width: 100%; gap: var(--space-6); }
.floating-data-window .client-os-group { width: 100%; max-width: 100%; }
.floating-data-window .client-os-group table { width: 100%; max-width: 100%; }
.floating-data-window .client-os-group th:first-child,
.floating-data-window .client-os-group td:first-child { white-space: nowrap; }
@media (max-width: 620px) {
.floating-data-window { min-width: 0; min-height: 0; }
.floating-data-window .results-window-resize { display: none; }
}
.client-info-bars { display: grid; align-content: start; gap: 0; margin: 0; padding: 0; list-style: none; } .client-info-bars { display: grid; align-content: start; gap: 0; margin: 0; padding: 0; list-style: none; }
.client-info-bars li { min-width: 0; border-bottom: var(--border-hair) solid var(--line); } .client-info-bars li { min-width: 0; border-bottom: var(--border-hair) solid var(--line); }
.client-info-bars li:last-child { border-bottom: 0; } .client-info-bars li:last-child { border-bottom: 0; }
@ -839,7 +863,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.results-browser-table { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); width: 100%; overflow-x: auto; } .results-browser-table { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); width: 100%; overflow-x: auto; }
.results-browser-row { .results-browser-row {
display: grid; display: grid;
grid-template-columns: minmax(280px, 1fr) 80px 120px 140px 50px 90px 110px 150px 110px; grid-template-columns: minmax(280px, 1fr) 160px 80px 120px 140px 50px 90px 110px 150px 110px;
gap: var(--legacy-space-3); align-items: center; padding: var(--legacy-space-2) var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--ui-control-size); min-width: fit-content; gap: var(--legacy-space-3); align-items: center; padding: var(--legacy-space-2) var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--ui-control-size); min-width: fit-content;
} }
.results-browser-row:first-child { border-top: 0; } .results-browser-row:first-child { border-top: 0; }
@ -850,9 +874,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.results-browser-row.expanded { background: var(--panel-raised); } .results-browser-row.expanded { background: var(--panel-raised); }
.results-browser-head { color: var(--muted); background: var(--panel-soft); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; padding: var(--legacy-space-2) var(--legacy-space-3); } .results-browser-head { color: var(--muted); background: var(--panel-soft); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; padding: var(--legacy-space-2) var(--legacy-space-3); }
.results-browser-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .results-browser-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.results-browser-row span[data-label="Titel"] { overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.35; overflow-wrap: anywhere; } .results-browser-row span[data-label="Title"] { overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.35; overflow-wrap: anywhere; }
.results-browser-row-detail { grid-column: 1 / -1; padding: var(--legacy-space-3); border-top: var(--border-hair) dashed var(--line); color: var(--muted); font-size: var(--ui-control-size); line-height: 1.55; } .results-browser-row-detail { grid-column: 1 / -1; min-width: 0; padding: var(--legacy-space-3); border-top: var(--border-hair) dashed var(--line); color: var(--muted); font-size: var(--ui-control-size); line-height: 1.55; overflow-wrap: anywhere; }
.results-browser-row-detail ul { margin: var(--legacy-space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--legacy-space-1); } .results-browser-row-detail ul { min-width: 0; margin: var(--legacy-space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--legacy-space-1); }
.results-environment-value { color: var(--text); font-size: var(--fs-lg); line-height: var(--lh-prose); } .results-environment-value { color: var(--text); font-size: var(--fs-lg); line-height: var(--lh-prose); }
.results-environment-value strong { color: var(--heading); } .results-environment-value strong { color: var(--heading); }
.results-environment-value p { margin: var(--legacy-space-1) 0 var(--legacy-space-3); overflow-wrap: anywhere; white-space: pre-wrap; } .results-environment-value p { margin: var(--legacy-space-1) 0 var(--legacy-space-3); overflow-wrap: anywhere; white-space: pre-wrap; }
@ -1468,15 +1492,23 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.tenant-report-status[data-status="reimport-recommended"], .tenant-report-status[data-status="reimport-recommended"],
.tenant-report-status[data-status="re-evaluation-required"], .tenant-report-status[data-status="re-evaluation-required"],
.tenant-report-status[data-status="review-required"] { color: var(--warning); } .tenant-report-status[data-status="review-required"] { color: var(--warning); }
.tenant-processing-alert { display: grid; gap: var(--space-3); margin: var(--space-4) var(--space-5); padding: var(--space-4); border: var(--bw-hairline) solid var(--warning); color: var(--text-primary); background: color-mix(in srgb, var(--warning) 8%, var(--surface-panel)); } .tenant-processing-alert {
.tenant-processing-alert > header { display: grid; gap: var(--space-1); } display: grid;
.tenant-processing-alert > header > strong { color: var(--warning); font-size: var(--fs-lg); } grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
color: var(--text-primary);
background: color-mix(in srgb, var(--warning) 8%, var(--surface-panel));
}
.tenant-processing-alert-inline {
padding: var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + var(--space-5) + var(--space-2));
border-bottom: var(--bw-hairline) solid var(--warning);
}
.tenant-processing-alert strong { color: var(--warning); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
.tenant-processing-alert[data-status="error"] { border-color: var(--state-offline); background: color-mix(in srgb, var(--state-offline) 8%, var(--surface-panel)); } .tenant-processing-alert[data-status="error"] { border-color: var(--state-offline); background: color-mix(in srgb, var(--state-offline) 8%, var(--surface-panel)); }
.tenant-processing-alert[data-status="error"] > header > strong { color: var(--state-offline); } .tenant-processing-alert[data-status="error"] strong { color: var(--state-offline); }
.tenant-processing-alert > header > span { color: var(--text-secondary); font-size: var(--fs-md); } .tenant-processing-alert p { margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); line-height: var(--lh-prose); }
.tenant-processing-alert-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-4); padding-top: var(--space-3); border-top: var(--bw-hairline) solid var(--border-subtle); } .tenant-processing-alert .btn { white-space: nowrap; }
.tenant-processing-alert-row p { margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--fs-md); line-height: var(--lh-prose); }
.tenant-processing-alert-row .btn { white-space: nowrap; }
.tenant-report-row input[type="checkbox"]:disabled { .tenant-report-row input[type="checkbox"]:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: .42; opacity: .42;
@ -1501,6 +1533,11 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
grid-template-columns: var(--space-5) minmax(0, 1fr) calc(var(--space-8) * 2.5); grid-template-columns: var(--space-5) minmax(0, 1fr) calc(var(--space-8) * 2.5);
align-items: start; align-items: start;
} }
.tenant-processing-alert-inline {
grid-template-columns: minmax(0, 1fr);
padding-left: var(--space-4);
}
.tenant-processing-alert-inline .btn { justify-self: start; }
.tenant-report-head { display: none; } .tenant-report-head { display: none; }
.tenant-report-row > :first-child { grid-column: 1; grid-row: 1; } .tenant-report-row > :first-child { grid-column: 1; grid-row: 1; }
.tenant-report-row > :not(:first-child) { grid-column: 2; } .tenant-report-row > :not(:first-child) { grid-column: 2; }

View File

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

View File

@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { discloseResultDbProjection, removeResultDbScopeIdentifiers, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../../src/domain/result-db-disclosure';
function unsafeScanner(): UnifiedResultProjection {
return {
resultProjectionId: 'scanner-finding:private-occurrence', sourceKind: 'scanner-finding',
sourceResultId: 'private-occurrence', projectId: 'private-company', assessmentId: 'private-assessment',
collectionRunId: 'private-run', scanRunId: 'private-run', sourceTool: 'maester', sourceRuleId: 'MT.1001',
sourceToolVersion: 'private-build', sourceProfileId: 'maester', canonicalDefinitionId: 'private-definition',
importArtifactId: 'private-artifact', sourceViewId: 'private-view', sourceLocator: { objectPath: 'private/path' },
title: 'Kerberos preauthentication is disabled for person@private-company.example',
description: 'Private Company GmbH tenant permits legacy authentication.',
recommendation: 'Disable legacy authentication and contact Jane Doe.', affectedAsset: 'private-company.example',
severityOriginal: 'Private severity', severityNormalized: 'high', outcomeOriginal: 'Private outcome',
outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'compliance_check',
isIoe: false, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1,
reviewStatus: 'open', reviewOwner: 'Jane Doe', reviewDueDate: '2026-10-01',
sourceObservedAt: '2026-09-22T08:00:00.000Z', importedAt: '2026-09-22T08:00:00.000Z',
createdAt: '2026-09-22T08:00:00.000Z',
categories: [{ namespace: 'private-company', categoryCode: 'private', categoryLabel: 'Private Company',
categoryType: 'source', isPrimary: true }]
};
}
describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
it('keeps useful known-scanner guidance while redacting customer identifiers', () => {
const disclosed = discloseResultDbProjection(unsafeScanner());
expect(disclosed).toMatchObject({
title: 'Kerberos preauthentication is disabled for [email]', sourceTool: 'maester', sourceRuleId: 'MT.1001',
description: '[organization] tenant permits legacy authentication.',
recommendation: 'Disable legacy authentication and contact [redacted].',
severityOriginal: 'high', outcomeOriginal: 'fail', importedAt: '1970-01-01T00:00:00.000Z',
categories: [{ namespace: 'source-tool', categoryCode: 'maester', categoryLabel: 'Maester' }]
});
const serialized = JSON.stringify(disclosed);
for (const privateValue of ['person@private-company.example', 'Private Company GmbH', 'Jane Doe',
'private-company.example', 'private/path', 'private-build', 'private-definition', 'private-artifact']) {
expect(serialized).not.toContain(privateValue);
}
});
it('retains Purple Knight native rule identity and remediation text', () => {
const disclosed = discloseResultDbProjection({
...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000055',
title: 'Privileged accounts have weak delegation settings',
description: 'Review delegation on PRIVATE\\admin-user and private-company.example.',
recommendation: '<p>Remove unconstrained delegation from the <b>affected account</b>.</p>',
affectedAsset: 'PRIVATE\\admin-user'
});
expect(disclosed).toMatchObject({
sourceTool: 'purple-knight', sourceRuleId: 'SI000055',
title: 'Privileged accounts have weak delegation settings',
description: 'Review delegation on [account] and [domain].',
recommendation: 'Remove unconstrained delegation from the affected account.'
});
});
it('publishes Purple Knight for Entra ID as its own Result DB source scope', () => {
const disclosed = discloseResultDbProjection({
...unsafeScanner(), sourceTool: 'purple-knight', sourceToolScope: 'entra-id', sourceRuleId: 'SI000055',
title: 'Synthetic Entra control', description: 'Synthetic Entra description.'
});
expect(disclosed).toMatchObject({
sourceTool: 'purple-knight-entra', sourceToolScope: 'entra-id', sourceRuleId: 'SI000055',
title: 'Synthetic Entra control',
categories: [{ namespace: 'source-tool', categoryCode: 'purple-knight-entra', categoryLabel: 'Purple Knight for Entra ID' }]
});
});
it('shows a publicly documented PingCastle ID in its native spelling and rejects an unknown ID', () => {
const item = { ...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'S-oldNtlm',
title: 'The LAN Manager Authentication Level allows the use of NTLMv1 or LM.' };
expect(discloseResultDbProjection(item)).toMatchObject({
sourceRuleId: 'S-oldNtlm', title: item.title
});
expect(discloseResultDbProjection({ ...item, sourceRuleId: 'S-PrivateCustomerRule' }).sourceRuleId)
.toBeUndefined();
});
it('keeps the complete technical GPO deviation without customer or source identity', () => {
const disclosed = discloseResultDbProjection({
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
title: 'Restrict driver installation to administrators',
description: 'Private Company GmbH configures this setting for dummy.local.',
recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.',
policyDeliveryMechanism: 'registry-policy', policyClassification: 'misconfigured',
policyActualValue: {
kind: 'registry-policy', operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
valueName: 'RestrictDriverInstallationToAdministrators', typeCode: 4, data: { kind: 'uint32', value: 0 },
principals: ['S-1-5-18', 'S-1-5-32-544', 'BUILTIN\\Administrators', 'DUMMY\\Domain Admins',
'DUMMY\\jane.doe', 'S-1-5-21-123456789-234567890-345678901-512'],
note: 'Configured by {A1B2C3D4-1111-2222-3333-444455556666}',
gpoName: 'DUMMY – Domain Controllers Security', gpoId: '{A1B2C3D4-1111-2222-3333-444455556666}',
sourcePath: '\\\\fileserver\\backups\\dummy.local\\gpo.zip'
},
policyBaselineValue: {
kind: 'registry-policy', operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
valueName: 'RestrictDriverInstallationToAdministrators', typeCode: 4, data: { kind: 'uint32', value: 1 }
},
policyIdentityKey: 'private-company-policy', policyGpoId: '{A1B2C3D4-1111-2222-3333-444455556666}',
policyNativeBackupId: '{99999999-1111-2222-3333-444455556666}',
policyBaselineId: 'private-baseline-id', policyBaselineLabel: 'Private Company Microsoft Security Baseline Windows Server 2022 v1',
policyDiagnosticCodes: ['policy-safe-code', 'Private Company'], policyComparisonRelation: 'different',
policyBaselineVariantAssessment: {
version: 1, scope: 'backup-configuration', decision: 'all-fail',
actualValues: [{ kind: 'principal-set', principals: ['DUMMY\\named.user'] }],
variants: [{
settingId: 'private-company-baseline-setting',
baselineValues: [{ kind: 'principal-set', principals: ['BUILTIN\\Administrators'] }],
comparator: { version: 1, comparator: { kind: 'set', mode: 'exact' }, relation: 'different',
actual: { kind: 'string-set', values: ['DUMMY\\named.user'] },
baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }, diagnosticCodes: [] }
}], diagnosticCodes: []
}
});
expect(disclosed).toMatchObject({
title: 'Restrict driver installation to administrators', sourceTool: 'GPOs',
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
policyActualValue: {
kind: 'registry-policy', operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
valueName: 'RestrictDriverInstallationToAdministrators', typeCode: 4, data: { kind: 'uint32', value: 0 },
principals: ['S-1-5-18', 'S-1-5-32-544', 'BUILTIN\\Administrators', '[DOMAIN]\\Domain Admins',
'[DOMAIN_PRINCIPAL_1]', 'S-1-5-21-[DOMAIN]-512']
},
policyBaselineValue: { data: { kind: 'uint32', value: 1 } },
policyBaselineLabel: 'Microsoft Security Baseline Windows Server 2022 v1',
recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.',
policyDiagnosticCodes: ['policy-safe-code'],
policyBaselineVariantAssessment: {
variants: [{ settingId: 'baseline-variant-1', comparator: {
actual: { kind: 'string-set', values: ['[DOMAIN_PRINCIPAL_2]'] },
baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }
}}]
}
});
expect(disclosed.policyIdentityKey).toBeUndefined();
expect(disclosed.policyBaselineId).toBeUndefined();
const serialized = JSON.stringify(disclosed);
for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY – Domain Controllers Security',
'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', '123456789', '234567890', '345678901']) {
expect(serialized).not.toContain(privateValue);
}
expect(serialized).toContain('Backup comparison; effective endpoint state not verified');
});
it('removes generated GPO identity hashes instead of presenting them as rule IDs', () => {
const disclosed = discloseResultDbProjection({
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
sourceRuleId: 'gpo-policy-identity-deadbeef', title: 'Synthetic setting',
policyBaselineLabel: 'Microsoft Security Baseline', policyActualValue: 0, policyBaselineValue: 1
});
expect(disclosed.sourceRuleId).toBeUndefined();
expect(disclosed.policyIdentityKey).toBeUndefined();
});
it('replaces customer scope handles after query filtering', () => {
expect(removeResultDbScopeIdentifiers(discloseResultDbProjection(unsafeScanner()))).toMatchObject({
projectId: 'scoped-project', assessmentId: 'scoped-assessment', collectionRunId: 'scoped-collection',
scanRunId: 'scoped-scan'
});
});
});

View File

@ -47,15 +47,16 @@ describe('filtered Result DB JSONL export', () => {
recordType: 'manifest', recordType: 'manifest',
format: RESULT_DB_JSONL_FORMAT, format: RESULT_DB_JSONL_FORMAT,
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
disclosurePolicyVersion: '2.5.0',
generatedAt: '2026-09-03T12:34:56.789Z', generatedAt: '2026-09-03T12:34:56.789Z',
resultCount: 2, resultCount: 2,
scope: { projectId: 'project-1', scanRunIds: ['scan-1'] }, scope: { projectScoped: true, assessmentScopeCount: 0, scanScopeCount: 1, evaluationScoped: false },
filters: { deviation: true, severity: ['critical', 'high'], search: 'synthetic' } filters: { deviation: true, severity: { active: true, selectionCount: 2 } }
}); });
expect(lines.slice(1)).toEqual([ expect(lines.slice(1)).toHaveLength(2);
{ recordType: 'result', position: 1, result: result('result-1') }, expect(lines[1]).toMatchObject({ recordType: 'result', position: 1,
{ recordType: 'result', position: 2, result: result('result-2') } result: { title: 'Security assessment finding', severityNormalized: 'high', outcomeNormalized: 'fail' } });
]); expect(text).not.toMatch(/project-1|assessment-1|scan-1|source-result-1|synthetic/i);
expect(text.endsWith('\n')).toBe(true); expect(text.endsWith('\n')).toBe(true);
expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z')) expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z'))
.toBe('assessor-result-db-filtered-2026-09-03T12-34-56Z.jsonl'); .toBe('assessor-result-db-filtered-2026-09-03T12-34-56Z.jsonl');
@ -119,4 +120,45 @@ describe('filtered Result DB JSONL export', () => {
expect(() => buildUnifiedResultJsonl({}, [unclassified], '2026-09-03T12:34:56.789Z')) expect(() => buildUnifiedResultJsonl({}, [unclassified], '2026-09-03T12:34:56.789Z'))
.toThrow('unclassified supplemental result'); .toThrow('unclassified supplemental result');
}); });
it('does not export personal, company, scope or provenance values from ordinary findings', () => {
const unsafe = {
...result('private-result'), sourceTool: 'maester', sourceRuleId: 'MT.1001',
title: 'Legacy authentication permits person@private-company.example', description: 'Private Company GmbH',
recommendation: 'Contact Jane Doe', affectedAsset: 'private-company.example',
reviewOwner: 'Jane Doe', sourceLocator: { objectPath: 'private/company/path' }
} satisfies UnifiedResultProjection;
const text = buildUnifiedResultJsonl({
projectId: 'private-company', scanRunIds: ['private-run'], search: 'person@private-company.example'
}, [unsafe], '2026-09-03T12:34:56.789Z');
expect(text).toContain('Legacy authentication permits [email]');
expect(text).toContain('[organization]');
expect(text).not.toMatch(/person@|Private Company|Jane Doe|private-company|private-run|private\/company\/path/i);
});
it('exports the same actionable GPO comparison shown in Result DB', () => {
const policy = {
...result('policy-result'), sourceKind: 'policy-evaluation' as const, sourceTool: 'GPOs',
sourceRuleId: 'registry:machine:software\\policies\\synthetic:mode', title: 'Synthetic policy mode',
policyIdentityKey: 'private-policy-hash', policyGpoId: '{A1B2C3D4-1111-2222-3333-444455556666}',
policyBaselineLabel: 'DUMMY Microsoft Security Baseline Windows Server 2022 v1',
policyActualValue: { operation: 'set-value', data: { kind: 'dword', value: 0 },
principals: ['BUILTIN\\Administrators', 'DUMMY\\Domain Admins', 'DUMMY\\named.user',
'S-1-5-21-123456789-234567890-345678901-512'] },
policyBaselineValue: { operation: 'set-value', data: { kind: 'dword', value: 1 } },
policyClassification: 'misconfigured' as const
} satisfies UnifiedResultProjection;
const text = buildUnifiedResultJsonl({}, [policy], '2026-09-23T10:00:00.000Z');
expect(text).toContain('Synthetic policy mode');
expect(text).toContain('registry:machine:software');
expect(text).toContain('policyActualValue');
expect(text).toContain('policyBaselineValue');
expect(text).toContain('BUILTIN\\\\Administrators');
expect(text).toContain('[DOMAIN]\\\\Domain Admins');
expect(text).toContain('[DOMAIN_PRINCIPAL_1]');
expect(text).toContain('S-1-5-21-[DOMAIN]-512');
expect(text).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|private-policy-hash/);
});
}); });

View File

@ -40,7 +40,8 @@ describe('canonical project loading for Work', () => {
expect(dataset).toMatchObject({ fileName: 'Test · All Scans', scanTool: 'PurpleKnight', profileId: 'purple-knight' }); expect(dataset).toMatchObject({ fileName: 'Test · All Scans', scanTool: 'PurpleKnight', profileId: 'purple-knight' });
expect(dataset?.sheets[0].analysis.records).toHaveLength(1); expect(dataset?.sheets[0].analysis.records).toHaveLength(1);
expect(dataset?.sheets[0].analysis.records[0].title).toBe('Finding'); expect(dataset?.sheets[0].analysis.records[0].title).toBe('Finding');
expect(dataset?.sheets[0].analysis.headers).toEqual(['Source: File', 'Source: Sheet', 'Source: Scan', 'Rule ID', 'Title', 'Severity']); expect(dataset?.sheets[0].analysis.headers).toEqual(['Source: File', 'Source: Sheet', 'Source: Scan', 'Source Rule ID', 'Title', 'Severity']);
expect(dataset?.sheets[0].analysis.records[0].fields.find(field => field.label === 'Source Rule ID')?.value).toBe('PK-1');
expect(dataset?.sheets[0].analysis.records[0].fields.find(field => field.label === 'Severity')?.value).toBe('high'); expect(dataset?.sheets[0].analysis.records[0].fields.find(field => field.label === 'Severity')?.value).toBe('high');
expect(dataset?.sheets[0].analysis.records[0]).toMatchObject({ expect(dataset?.sheets[0].analysis.records[0]).toMatchObject({
findingOccurrenceId: 'occurrence-1', findingDefinitionId: 'definition-1' findingOccurrenceId: 'occurrence-1', findingDefinitionId: 'definition-1'
@ -254,8 +255,62 @@ describe('canonical project loading for Work', () => {
expect(dataset?.sheets[1].analysis.records).toHaveLength(1); expect(dataset?.sheets[1].analysis.records).toHaveLength(1);
}); });
it('uses the Result DB definition title throughout Work while retaining a differing native source title', () => {
const native = model();
native.scanRuns = [{
...native.scanRuns[0],
tool: { id: 'pingcastle', name: 'PingCastle' }
}];
native.importFiles = [{
...native.importFiles[0],
fileName: 'ad_hc_synthetic.example.xml',
format: 'XML',
detectedTool: { id: 'pingcastle', name: 'PingCastle' }
}];
native.sourceTables = [];
native.findingDefinitions = [{
...native.findingDefinitions[0],
title: 'Number of synthetic computers without password change for at least 3 months: 6'
}];
native.findingOccurrences = [{
...native.findingOccurrences[0],
sourceTableId: undefined,
sourceIdentity: {
tool: { id: 'pingcastle', name: 'PingCastle' },
originalRuleId: 'S-SyntheticPasswordAge',
canonicalRuleId: 'S-SyntheticPasswordAge',
originalTitle: 'Check if all synthetic computers changed their passwords in the last 3 months.'
},
originalFields: [
{ key: 'risk-id', label: 'Rule ID', value: 'S-SyntheticPasswordAge' },
{ key: 'rationale', label: 'Title', value: 'Check if all synthetic computers changed their passwords in the last 3 months.' }
],
fieldMappings: [{
targetRole: 'title', sourceFieldKeys: ['rationale'],
normalizedValue: 'Check if all synthetic computers changed their passwords in the last 3 months.',
provenance: native.findingOccurrences[0].severity.provenance
}]
}];
const dataset = canonicalModelToDataset(native)!;
const expected = 'Number of synthetic computers without password change for at least 3 months: 6';
const records = dataset.sheets.flatMap(sheet => sheet.analysis.records)
.filter(record => record.findingOccurrenceId === 'occurrence-1');
expect(records).toHaveLength(2);
expect(records.map(record => record.title)).toEqual([expected, expected]);
expect(records.every(record => record.fields.find(field => field.label === 'Title')?.value === expected)).toBe(true);
expect(records.every(record => record.fields.find(field => field.label === 'Native source heading')?.value
=== 'Check if all synthetic computers changed their passwords in the last 3 months.')).toBe(true);
expect(native.findingOccurrences[0].originalFields.find(field => field.label === 'Title')?.value)
.toBe('Check if all synthetic computers changed their passwords in the last 3 months.');
});
it('keeps PingCastle HTML and XML findings separate even when file stem and Rule ID match', () => { it('keeps PingCastle HTML and XML findings separate even when file stem and Rule ID match', () => {
const html = model(); const html = model();
const checkTitle = 'Ensure that synthetic NTLMv1 and LM protocols are banned';
const resultTitle = 'The synthetic LAN Manager Authentication Level allows NTLMv1 or LM.';
html.findingDefinitions = [{ ...html.findingDefinitions[0], title: checkTitle }];
html.scanRuns = [{ ...html.scanRuns[0], tool: { id: 'pingcastle', name: 'PingCastle' } }]; html.scanRuns = [{ ...html.scanRuns[0], tool: { id: 'pingcastle', name: 'PingCastle' } }];
html.importFiles = [{ html.importFiles = [{
...html.importFiles[0], ...html.importFiles[0],
@ -269,13 +324,13 @@ describe('canonical project loading for Work', () => {
sourceTableId: undefined, sourceTableId: undefined,
sourceIdentity: { sourceIdentity: {
tool: { id: 'pingcastle', name: 'PingCastle' }, tool: { id: 'pingcastle', name: 'PingCastle' },
originalRuleId: 'S-Synthetic', originalRuleId: 'S-oldNtlm',
canonicalRuleId: 'S-Synthetic', canonicalRuleId: 'S-oldNtlm',
originalTitle: 'Synthetic finding' originalTitle: checkTitle
}, },
originalFields: [ originalFields: [
{ key: 'risk-id', label: 'Rule ID', value: 'S-Synthetic' }, { key: 'risk-id', label: 'Rule ID', value: 'S-oldNtlm' },
{ key: 'title', label: 'Title', value: 'Synthetic finding' }, { key: 'title', label: 'Title', value: checkTitle },
{ key: 'description', label: 'Description', value: 'Synthetic manufacturer description.' }, { key: 'description', label: 'Description', value: 'Synthetic manufacturer description.' },
{ key: 'technical', label: 'Technical explanation', value: 'Synthetic technical explanation.' }, { key: 'technical', label: 'Technical explanation', value: 'Synthetic technical explanation.' },
{ key: 'solution', label: 'Advised solution', value: 'Synthetic remediation.' }, { key: 'solution', label: 'Advised solution', value: 'Synthetic remediation.' },
@ -298,6 +353,7 @@ describe('canonical project loading for Work', () => {
const xml = model(); const xml = model();
xml.assessment = { ...xml.assessment, id: 'assessment-xml' }; xml.assessment = { ...xml.assessment, id: 'assessment-xml' };
xml.findingDefinitions = [{ ...xml.findingDefinitions[0], title: resultTitle }];
xml.scanRuns = [{ xml.scanRuns = [{
...xml.scanRuns[0], ...xml.scanRuns[0],
id: 'run-xml', id: 'run-xml',
@ -321,13 +377,13 @@ describe('canonical project loading for Work', () => {
sourceTableId: undefined, sourceTableId: undefined,
sourceIdentity: { sourceIdentity: {
tool: { id: 'pingcastle', name: 'PingCastle' }, tool: { id: 'pingcastle', name: 'PingCastle' },
originalRuleId: 'S-Synthetic', originalRuleId: 'S-oldNtlm',
canonicalRuleId: 'S-Synthetic', canonicalRuleId: 'S-oldNtlm',
originalTitle: 'Synthetic finding' originalTitle: resultTitle
}, },
originalFields: [ originalFields: [
{ key: 'risk-id', label: 'Rule ID', value: 'S-Synthetic' }, { key: 'risk-id', label: 'Rule ID', value: 'S-oldNtlm' },
{ key: 'title', label: 'Title', value: 'Synthetic finding' }, { key: 'title', label: 'Title', value: resultTitle },
{ key: 'points', label: 'Points', value: '20' } { key: 'points', label: 'Points', value: '20' }
] ]
}]; }];
@ -344,9 +400,22 @@ describe('canonical project loading for Work', () => {
expect(field('Rule Text Source')).toBeUndefined(); expect(field('Rule Text Source')).toBeUndefined();
expect(field('Verwandte Daten')).toBeUndefined(); expect(field('Verwandte Daten')).toBeUndefined();
const xmlRecord = xmlSheet!.analysis.records[0]; const xmlRecord = xmlSheet!.analysis.records[0];
expect(xmlRecord.title).toBe(resultTitle);
expect(linkPingCastleSectionSheets(xmlRecord, dataset!.sheets)).toEqual([]); expect(linkPingCastleSectionSheets(xmlRecord, dataset!.sheets)).toEqual([]);
const htmlSheet = dataset?.sheets.find(sheet => sheet.sourceFile === 'ad_hc_synthetic.example.html'); const htmlSheet = dataset?.sheets.find(sheet => sheet.sourceFile === 'ad_hc_synthetic.example.html');
const htmlRecord = htmlSheet!.analysis.records[0]; const htmlRecord = htmlSheet!.analysis.records[0];
expect(htmlRecord.title).toBe(resultTitle);
expect(htmlRecord.fields.find(field => field.label === 'Source Rule ID')?.value).toBe('S-oldNtlm');
expect(htmlRecord.fields.find(field => field.label === 'Native source heading')?.value).toBe(checkTitle);
expect(canonicalModelsToDataset([html], [html, xml])?.sheets[0].analysis.records[0].title).toBe(resultTitle);
const unrelated = structuredClone(xml);
unrelated.importFiles[0].fileName = 'ad_hc_other.example.xml';
expect(canonicalModelsToDataset([html], [html, unrelated])?.sheets[0].analysis.records[0].title)
.toBe(checkTitle);
const conflicting = structuredClone(xml);
conflicting.findingDefinitions[0].title = 'Conflicting synthetic report title';
expect(canonicalModelsToDataset([html], [html, xml, conflicting])?.sheets[0].analysis.records[0].title)
.toBe(checkTitle);
expect(linkPingCastleSectionSheets(htmlRecord, dataset!.sheets)).toHaveLength(1); expect(linkPingCastleSectionSheets(htmlRecord, dataset!.sheets)).toHaveLength(1);
expect(xml.findingOccurrences[0].originalFields).toHaveLength(3); expect(xml.findingOccurrences[0].originalFields).toHaveLength(3);
}); });
@ -434,8 +503,8 @@ describe('canonical project loading for Work', () => {
expect(sectionSheets).toHaveLength(2); expect(sectionSheets).toHaveLength(2);
for (const [ruleId, referenceId] of expectedReferences) { for (const [ruleId, referenceId] of expectedReferences) {
const sheet = dataset.sheets.find(item => item.analysis.records.some(record => record.fields.find(field => field.label === 'Rule ID')?.value === ruleId))!; const sheet = dataset.sheets.find(item => item.analysis.records.some(record => record.fields.find(field => field.label === 'Source Rule ID')?.value === ruleId))!;
const record = sheet.analysis.records.find(item => item.fields.find(field => field.label === 'Rule ID')?.value === ruleId)!; const record = sheet.analysis.records.find(item => item.fields.find(field => field.label === 'Source Rule ID')?.value === ruleId)!;
expect(pingCastleSectionIds(record)).toEqual([referenceId]); expect(pingCastleSectionIds(record)).toEqual([referenceId]);
expect(linkPingCastleSectionSheets(record, dataset.sheets).map(item => item.id)).toEqual([ expect(linkPingCastleSectionSheets(record, dataset.sheets).map(item => item.id)).toEqual([
pingCastleSectionSheetId(referenceId) pingCastleSectionSheetId(referenceId)

View File

@ -117,8 +117,8 @@ describe('client info environment detail', () => {
// A present observation whose value is an empty string, explicit null, or // A present observation whose value is an empty string, explicit null, or
// boolean must stay an em dash: Number('')/Number(null)/Number(false) are all 0. // boolean must stay an em dash: Number('')/Number(null)/Number(false) are all 0.
expect(fact(detail.toolContext, 'CIS-CAT score')).toBe('—'); expect(fact(detail.toolContext, 'CIS-CAT score')).toBe('—');
expect(fact(detail.toolContext, 'Purple Knight IOEs')).toBe('—'); expect(fact(detail.toolContext, 'Purple Knight AD IOEs')).toBe('—');
expect(fact(detail.toolContext, 'Purple Knight posture')).toBe('—'); expect(fact(detail.toolContext, 'Purple Knight AD posture')).toBe('—');
// A table column whose cells are all blank must not sum to a fabricated 0. // A table column whose cells are all blank must not sum to a fabricated 0.
expect(fact(detail.inventory, 'User accounts')).toBe('—'); expect(fact(detail.inventory, 'User accounts')).toBe('—');
}); });
@ -138,9 +138,9 @@ describe('client info environment detail', () => {
it('keeps native tool metrics labelled and lists restricted sections instead of counting them as zero', () => { it('keeps native tool metrics labelled and lists restricted sections instead of counting them as zero', () => {
const detail = dashboardEnvironmentDetail(snapshot); const detail = dashboardEnvironmentDetail(snapshot);
expect(fact(detail.toolContext, 'Purple Knight posture')).toBe('65'); expect(fact(detail.toolContext, 'Purple Knight AD posture')).toBe('65');
expect(fact(detail.toolContext, 'Purple Knight IOEs')).toBe('19'); expect(fact(detail.toolContext, 'Purple Knight AD IOEs')).toBe('19');
expect(fact(detail.toolContext, 'Kerberos Security')).toBe('98'); expect(fact(detail.toolContext, 'Purple Knight AD · Kerberos Security')).toBe('98');
expect(fact(detail.toolContext, 'CIS-CAT benchmark')).toBe('Synthetic CIS Benchmark'); expect(fact(detail.toolContext, 'CIS-CAT benchmark')).toBe('Synthetic CIS Benchmark');
expect(fact(detail.toolContext, 'CIS-CAT score')).toBe('61'); expect(fact(detail.toolContext, 'CIS-CAT score')).toBe('61');
expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']); expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']);
@ -182,6 +182,8 @@ describe('client info environment detail', () => {
expect(styles).toMatch(/\.client-environment-sources li em\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s); expect(styles).toMatch(/\.client-environment-sources li em\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
expect(styles).toMatch(/\.client-command-center \.client-os-group th\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s); expect(styles).toMatch(/\.client-command-center \.client-os-group th\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
expect(styles).toMatch(/\.client-command-center \.client-os-group \.client-os-source\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s); expect(styles).toMatch(/\.client-command-center \.client-os-group \.client-os-source\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
expect(styles).toMatch(/\.floating-data-window \.client-os-groups\s*\{[^}]*grid-template-columns:\s*max-content;[^}]*width:\s*max-content;/s);
expect(styles).toMatch(/\.floating-data-window \.client-os-group table\s*\{[^}]*width:\s*100%;/s);
expect(styles).toMatch(/\.client-domain-score em\s*\{[^}]*color:\s*var\(--chart-color, var\(--client-info-text\)\);/s); expect(styles).toMatch(/\.client-domain-score em\s*\{[^}]*color:\s*var\(--chart-color, var\(--client-info-text\)\);/s);
}); });
}); });

View File

@ -109,9 +109,11 @@ describe('Datahub centered reference rack layout', () => {
expect(datahub).toContain('selectedTimeslot.assessmentIds'); expect(datahub).toContain('selectedTimeslot.assessmentIds');
expect(datahub).toContain('summarizeWorkspaceResults(sheet.analysis.records, profileForSheet(dataset, sheet))'); expect(datahub).toContain('summarizeWorkspaceResults(sheet.analysis.records, profileForSheet(dataset, sheet))');
expect(datahub).toContain("'Reimport recommended'"); expect(datahub).toContain("'Reimport recommended'");
expect(datahub).toContain('className="tenant-processing-alert"'); expect(datahub).toContain('className="tenant-processing-alert tenant-processing-alert-inline"');
expect(datahub).toContain('New ASSESSOR processing profile available'); expect(datahub).toContain('New ASSESSOR processing profile available');
expect(datahub).toContain('ASSESSOR has updated its internal scanner-processing profile.'); expect(datahub).toContain("aria-label={`${report.tool.name}: ${processingHeading}`}");
expect(datahub).toContain("processing.state !== 'ready' && <section");
expect(datahub).not.toContain('{affectedReports.length > 0 && <section');
expect(datahub).toContain('replacesScanRunId={report.scanRunId}'); expect(datahub).toContain('replacesScanRunId={report.scanRunId}');
expect(datahub).toContain('targetAssessmentId={report.assessmentIds[0]}'); expect(datahub).toContain('targetAssessmentId={report.assessmentIds[0]}');
expect(datahub).toContain('Use imported {report.tool.name}'); expect(datahub).toContain('Use imported {report.tool.name}');
@ -127,6 +129,7 @@ describe('Datahub centered reference rack layout', () => {
expect(datahubTableStyles).toContain('.tenant-report-status[data-status="reimport-recommended"],'); expect(datahubTableStyles).toContain('.tenant-report-status[data-status="reimport-recommended"],');
expect(datahubTableStyles).toContain('.tenant-report-status[data-status="review-required"] { color: var(--warning); }'); expect(datahubTableStyles).toContain('.tenant-report-status[data-status="review-required"] { color: var(--warning); }');
expect(datahubTableStyles).toContain('.tenant-processing-alert[data-status="error"]'); expect(datahubTableStyles).toContain('.tenant-processing-alert[data-status="error"]');
expect(datahubTableStyles).toContain('.tenant-processing-alert-inline {');
expect(datahub).toContain('const SHOW_MITRE_MATRIX_LIBRARY = false;'); expect(datahub).toContain('const SHOW_MITRE_MATRIX_LIBRARY = false;');
expect(datahub).toContain('{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary'); expect(datahub).toContain('{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary');
expect(datahubStyles).toContain('.datahub-reference-row.is-mitre-hidden > .baseline-catalog { grid-area: 1 / 1 / 2 / -1; }'); expect(datahubStyles).toContain('.datahub-reference-row.is-mitre-hidden > .baseline-catalog { grid-area: 1 / 1 / 2 / -1; }');

View File

@ -34,7 +34,7 @@ function file(scanRunId: string, profileId: string, profileVersion: string, comp
function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord { function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord {
return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } }; return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } };
} }
const currentPurpleKnight = (scanRunId: string) => file(scanRunId, 'purple-knight', '1.3.0', 'purple-knight-canonical-v1.1'); const currentPurpleKnight = (scanRunId: string) => file(scanRunId, 'purple-knight', '1.4.0', 'purple-knight-canonical-v1.1');
describe('Datahub client processing readiness', () => { describe('Datahub client processing readiness', () => {
it('keeps a complete 22-file PurpleKnight import ready after serialization and repeated import', async () => { it('keeps a complete 22-file PurpleKnight import ready after serialization and repeated import', async () => {

View File

@ -194,6 +194,89 @@ describe('dynamic facets', () => {
expect(records.map(record => record.title)).toEqual(['Firewall disabled']); expect(records.map(record => record.title)).toEqual(['Firewall disabled']);
}); });
it('searches visible Work titles and fields without case sensitivity', () => {
const title = 'Presence of Admin accounts which do not have the flag "This account is sensitive and cannot be delegated": 17';
const searchSheet = makeSheet('PingCastle findings', [
['Title', 'Source Rule ID', 'Status', 'Source: Scan'],
[title, 'S-ADMIN-DELEGATION', 'Matched', 'PingCastle · Synthetic'],
['Unrelated synthetic finding', 'S-OTHER', 'Matched', 'PingCastle · Synthetic']
], '', 'synthetic-pingcastle.html', { toolId: 'pingcastle' });
const options = { activeProfile: genericProfile, sortColumn: 'all', sortDirection: 'asc' as const,
statusFilter: 'all', facetFilters: {}, actionOnly: false, reviews: {} };
expect(filterAndSortRecords(searchSheet, { ...options,
search: ' PRESENCE OF ADMIN ACCOUNTS WHICH DO NOT HAVE THE FLAG ' }).map(record => record.title)).toEqual([title]);
expect(filterAndSortRecords(searchSheet, { ...options,
search: 's-admin-delegation' }).map(record => record.title)).toEqual([title]);
});
it('classifies Maester ORCA and Entra suites instead of exposing them as Unclassified', () => {
const maesterSheet = makeSheet('Maester findings', [
['Title', 'Block', 'ID', 'Tag', 'Status', 'Source: Scan'],
['ORCA.104: High Confidence Phish action set to Quarantine message.', 'ORCA', 'ORCA.104',
'["ORCA","ORCA.104","EXO","Security","All"]', 'Failed', 'Maester · Synthetic'],
['Default Settings control is configured.', 'EIDSCA', 'EIDSCA.ST09',
'["EIDSCA","EIDSCA.ST09"]', 'Failed', 'Maester · Synthetic'],
['Conditional access policy is missing.', 'Maester', 'MT.1001',
'["Maester/Entra","Security"]', 'Failed', 'Maester · Synthetic']
], '', 'Maester.html', { toolId: 'maester' });
const facet = deriveDynamicFacets(maesterSheet, null, genericProfile, {})
.find(item => item.id === 'derived:security-domain')!;
expect(facet.values.find(value => value.value === 'cloud')?.count).toBe(2);
expect(facet.values.find(value => value.value === 'authentication')?.count).toBe(1);
expect(facet.values.some(value => value.value === 'unclassified')).toBe(false);
expect(filterAndSortRecords(maesterSheet, { activeProfile: genericProfile, search: '', sortColumn: 'all',
sortDirection: 'asc', statusFilter: 'all', facetFilters: { [facet.id]: { values: ['cloud'] } },
actionOnly: false, reviews: {} }).map(record => record.title)).toEqual([
'ORCA.104: High Confidence Phish action set to Quarantine message.',
'Default Settings control is configured.'
]);
});
it('classifies the retained PingCastle infrastructure and hardening findings from Work', () => {
const pingCastleSheet = makeSheet('PingCastle findings', [
['Title', 'Source Rule ID', 'Technical explanation', 'Status', 'Source: Scan'],
['Obsolete OS (Windows 7)', '', '', 'Matched', 'PingCastle · Synthetic'],
['Check if the Channel Binding is enabled for LDAPS', '', '', 'Matched', 'PingCastle · Synthetic'],
['Hardened Paths weakness', '', '', 'Matched', 'PingCastle · Synthetic'],
['Verify if there are restrictions for internet connectivity of script engines', '', '', 'Matched', 'PingCastle · Synthetic'],
['Check if OUs and Containers are protected from accidental deletion.', '', '', 'Matched', 'PingCastle · Synthetic'],
['Check if the mitigation for CVE-2021-42291 has been enabled', '', '', 'Matched', 'PingCastle · Synthetic'],
['Ensure that the rootDse Anonymous Binding is disabled', '', '', 'Matched', 'PingCastle · Synthetic'],
['Check if NTFRS is used to replicate SYSVOL', '', '', 'Matched', 'PingCastle · Synthetic'],
['Check if LDAPS is using TLS 1.0 or TLS 1.1.', '', '', 'Matched', 'PingCastle · Synthetic'],
['Ensure that the Print Spooler service cannot be abused to get the DC credentials',
'A-DC-Spooler', '', 'Matched', 'PingCastle · Synthetic'],
['Synthetic service configuration without a descriptive scanner title',
'CUSTOM-PRINT-SPOOLER-CONTROL', '', 'Matched', 'Synthetic Scanner'],
['Synthetic finding with narrative-only evidence', '',
'The Print Spooler service should be disabled on affected systems.', 'Matched', 'Synthetic Scanner']
], '', 'synthetic-pingcastle.html', { toolId: 'pingcastle' });
const facet = deriveDynamicFacets(pingCastleSheet, null, genericProfile, {})
.find(item => item.id === 'derived:security-domain')!;
expect(facet.values.find(value => value.value === 'hardening')?.count).toBe(7);
expect(facet.values.find(value => value.value === 'authentication')?.count).toBe(3);
expect(facet.values.find(value => value.value === 'infrastructure')?.count).toBe(2);
expect(facet.values.some(value => value.value === 'unclassified')).toBe(false);
expect(filterAndSortRecords(pingCastleSheet, { activeProfile: genericProfile, search: '', sortColumn: 'all',
sortDirection: 'asc', statusFilter: 'all', facetFilters: { [facet.id]: { values: ['hardening'] } },
actionOnly: false, reviews: {} }).map(record => record.title)).toContain(
'Ensure that the Print Spooler service cannot be abused to get the DC credentials');
});
it('uses Unclassified only when available fallback evidence would require guessing', () => {
const ambiguousSheet = makeSheet('Synthetic findings', [
['Title', 'Technical explanation', 'Status', 'Source: Scan'],
['Opaque finding', 'Review account passwords and backup monitoring together.', 'Failed', 'Synthetic Scanner']
], '', 'synthetic.csv', { toolId: 'generic' });
const facet = deriveDynamicFacets(ambiguousSheet, null, genericProfile, {})
.find(item => item.id === 'derived:security-domain')!;
expect(facet.values.find(value => value.value === 'unclassified')?.count).toBe(1);
});
it('provides a final Source: Scan control when a sheet has no explicit scan column', () => { it('provides a final Source: Scan control when a sheet has no explicit scan column', () => {
const fallbackSheet = makeSheet('Findings', [['Title', 'Severity'], ['A', 'High'], ['B', 'Low']], '', 'synthetic.csv'); const fallbackSheet = makeSheet('Findings', [['Title', 'Severity'], ['A', 'High'], ['B', 'Low']], '', 'synthetic.csv');
const facets = deriveDynamicFacets(fallbackSheet, { scanTool: 'Synthetic Scanner' } as never, genericProfile, {}); const facets = deriveDynamicFacets(fallbackSheet, { scanTool: 'Synthetic Scanner' } as never, genericProfile, {});

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding'; import type { FindingRecord } from '../../src/domain/finding';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
import { FindingReader, policyAnalyzerSearchKeys, selectFindingFields } from '../../src/features/finding-reader/FindingReader'; import { FindingReader, policyAnalyzerSearchKeys, selectFindingFields } from '../../src/features/finding-reader/FindingReader';
import { maesterFindingTitle } from '../../src/profiles/maester/presentation';
const record: FindingRecord = { const record: FindingRecord = {
id: 'finding-1', id: 'finding-1',
@ -501,4 +502,74 @@ describe('selectFindingFields', () => {
expect(selected.summary.map(field => field.label)).not.toContain('Result'); expect(selected.summary.map(field => field.label)).not.toContain('Result');
expect(selected.remainingFields.map(field => field.label)).not.toContain('Result'); expect(selected.remainingFields.map(field => field.label)).not.toContain('Result');
}); });
it('shows the canonical Source Rule ID once in the Work reader', () => {
const record: FindingRecord = {
...maesterRecord,
fields: maesterRecord.fields.map(field => field.label === 'ID'
? { ...field, label: 'Source Rule ID' }
: field)
};
const selected = selectFindingFields(record, record.title, false, 'maester');
expect(selected.sourceRuleId?.value).toBe('MT.1011');
expect(selected.summary.map(field => field.label)).not.toContain('ID');
expect(selected.remainingFields.map(field => field.label)).not.toContain('Source Rule ID');
const html = renderToStaticMarkup(createElement(FindingReader, {
record, title: record.title, toolId: 'maester', linkedSheets: [], resultReferences: [], search: '',
position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {}
}));
expect(html).toContain('data-source-rule-id="true"');
expect(html).toContain('<h4>Source Rule ID</h4><p><code class="mono">MT.1011</code></p>');
});
it('presents the retained Maester fields in the native report hierarchy without duplicating the ID in the title', () => {
const sourcePath = 'C:\\Repo\\Customer\\Maester\\tests\\cisa\\exchange\\Test-MtCisaAuditLogRetention.Tests.ps1';
const cisaRecord: FindingRecord = {
id: 'maester-cisa-exo-17-3', index: 0, filled: [], meta: [],
title: 'CISA.MS.EXO.17.3: Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).',
fields: [
{ index: 0, label: 'ID', value: 'CISA.MS.EXO.17.3' },
{ index: 1, label: 'Title', value: 'Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).' },
{ index: 2, label: 'Result', value: 'Failed' },
{ index: 3, label: 'Severity', value: 'Medium' },
{ index: 4, label: 'Tag', value: '["MS.EXO","MS.EXO.17.3","CISA.MS.EXO.17.3","CISA","Security","All"]' },
{ index: 5, label: 'Block', value: 'CISA' },
{ index: 6, label: 'Duration', value: '00:00:00' },
{ index: 7, label: 'ErrorMessage', value: '["Synthetic execution detail"]' },
{ index: 8, label: 'Name', value: 'CISA.MS.EXO.17.3: Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).' },
{ index: 9, label: 'TestScriptFile', value: sourcePath },
{ index: 10, label: 'TestScript', value: '$result = Test-MtCisaAuditLogRetention\n\nif ($null -ne $result) {\n $result | Should -Be $true -Because "enabled."\n}' },
{ index: 11, label: 'Index', value: '63' }
]
};
const title = maesterFindingTitle(cisaRecord, cisaRecord.title);
const selected = selectFindingFields(cisaRecord, title, false, 'maester');
expect(title).toBe('Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).');
expect(selected.summary.map(field => field.label)).toEqual(['ID', 'Test result', 'Test details', 'Category', 'Tags', 'Source']);
expect(selected.summary.find(field => field.label === 'ID')?.value).toBe('CISA.MS.EXO.17.3');
expect(selected.summary.find(field => field.label === 'Test result')?.value).toBe('Test failed.');
expect(selected.summary.find(field => field.label === 'Tags')?.value).toBe('MS.EXO · MS.EXO.17.3 · CISA.MS.EXO.17.3 · CISA · Security · All');
expect(selected.optionalFields).toContainEqual(expect.objectContaining({ label: 'Technical execution detail', value: 'Synthetic execution detail' }));
expect(selected.remainingFields.map(field => field.label)).not.toEqual(expect.arrayContaining([
'ID', 'Block', 'Duration', 'ErrorMessage', 'Name', 'TestScriptFile', 'TestScript', 'Index'
]));
const html = renderToStaticMarkup(createElement(FindingReader, {
record: cisaRecord, title, toolId: 'maester', linkedSheets: [], resultReferences: ['Failed'], search: '',
position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {}
}));
expect(html).toContain('<h2>Audit logs SHALL be maintained');
expect(html).not.toContain('<h2>CISA.MS.EXO.17.3:');
expect(html).toContain('<h4>ID</h4><p><code class="mono">CISA.MS.EXO.17.3</code></p>');
expect(html).toContain('<h4>Test result</h4><p>Test failed.</p>');
expect(html).toContain('<h4>Test details</h4>');
expect(html).toContain('class="rich-text-code"');
expect(html).toContain('<h4>Category</h4><p>CISA</p>');
expect(html).toContain('<h4>Tags</h4>');
expect(html).toContain('<h4>Source</h4>');
expect(html).toContain('Technical execution detail</summary>');
expect(html).not.toContain('Results (reference found)');
});
}); });

View File

@ -160,9 +160,66 @@ describe('canonical project workspace loading', () => {
expect(reader.listProjectAssessments).toHaveBeenCalledWith(model.project.id); expect(reader.listProjectAssessments).toHaveBeenCalledWith(model.project.id);
expect(reader.loadAssessmentModel).toHaveBeenCalledWith(model.assessment.id); expect(reader.loadAssessmentModel).toHaveBeenCalledWith(model.assessment.id);
expect(workspace.dataset?.profileId).toBe('pingcastle'); expect(workspace.dataset?.profileId).toBe('pingcastle');
expect(workspace.dataset?.sheets[1].analysis.headers).toEqual(expect.arrayContaining(['Rule ID', 'Title', 'Status'])); expect(workspace.dataset?.sheets[1].analysis.headers).toEqual(expect.arrayContaining(['Source Rule ID', 'Title', 'Status']));
expect(workspace.dataset?.sheets[1].analysis.headers).not.toContain('DomainSid'); expect(workspace.dataset?.sheets[1].analysis.headers).not.toContain('DomainSid');
expect(workspace.dataset?.sheets[1].analysis.records[0].title).toBe('Synthetic inactive privileged account'); expect(workspace.dataset?.sheets[1].analysis.records[0].title).toBe('Synthetic inactive privileged account');
expect(pingCastleProfile.defaults(workspace.dataset?.sheets[1]).titleColumn).toBe('Title'); expect(pingCastleProfile.defaults(workspace.dataset?.sheets[1]).titleColumn).toBe('Title');
}); });
it('opens a selected PingCastle HTML finding with the Result DB title from its paired XML report', async () => {
const imported = await importFile(
new File([pingCastleXml], 'ad_hc_synthetic.example.xml', { type: 'application/xml' }),
{ profiles: importProfiles }
);
const preview = previewEntryFromDataset(imported, {
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: pingCastleXml.length, lastModified: 0
});
const source = createCanonicalImportBatch('pingcastle-paired-report', [
confirmEntryMappings(preview, importProfiles)
], '2026-09-23T12:00:00.000Z').model;
const resultTitle = 'The synthetic LAN Manager Authentication Level allows NTLMv1 or LM.';
const checkTitle = 'Ensure that synthetic NTLMv1 and LM protocols are banned';
const xml = structuredClone(source);
xml.assessment.id = 'assessment-xml';
xml.scanRuns[0].assessmentId = 'assessment-xml';
xml.findingDefinitions[0].title = resultTitle;
xml.findingOccurrences[0].sourceIdentity.originalRuleId = 'S-oldNtlm';
xml.findingOccurrences[0].sourceIdentity.canonicalRuleId = 'S-oldNtlm';
xml.findingOccurrences[0].sourceIdentity.originalTitle = resultTitle;
const html = structuredClone(xml);
html.assessment.id = 'assessment-html';
html.scanRuns[0].id = 'run-html';
html.scanRuns[0].assessmentId = 'assessment-html';
html.importFiles[0].id = 'file-html';
html.importFiles[0].scanRunId = 'run-html';
html.importFiles[0].fileName = 'ad_hc_synthetic.example.html';
html.importFiles[0].format = 'HTML';
html.findingDefinitions[0].title = checkTitle;
html.findingOccurrences[0].id = 'occurrence-html';
html.findingOccurrences[0].scanRunId = 'run-html';
html.findingOccurrences[0].importFileId = 'file-html';
html.findingOccurrences[0].sourceIdentity.originalTitle = checkTitle;
html.findingOccurrences[0].originalFields = [
{ key: 'risk-id', label: 'Rule ID', value: 'S-oldNtlm' },
{ key: 'title', label: 'Title', value: checkTitle },
{ key: 'description', label: 'Description', value: 'Synthetic scanner explanation.' }
];
const models = new Map([[html.assessment.id, html], [xml.assessment.id, xml]]);
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([html.assessment, xml.assessment]),
loadAssessmentModel: vi.fn(async (id: string) => models.get(id)!)
};
const workspace = await loadCanonicalProjectWorkspace(reader, html.project.id,
['run-html'], ['assessment-html', 'assessment-xml']);
const records = workspace.dataset?.sheets[0].analysis.records ?? [];
expect(workspace.models).toHaveLength(1);
expect(records).toHaveLength(1);
expect(records[0].title).toBe(resultTitle);
expect(records[0].fields.find(field => field.label === 'Source Rule ID')?.value).toBe('S-oldNtlm');
expect(records[0].fields.find(field => field.label === 'Native source heading')?.value).toBe(checkTitle);
expect(html.findingOccurrences[0].sourceIdentity.originalTitle).toBe(checkTitle);
});
}); });

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { dashboardEnvironmentDetail, dashboardMaesterTenantCount } from '../../src/features/dashboard/DashboardPanel'; import { dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail } from '../../src/features/dashboard/DashboardPanel';
import { maesterEnvironmentContext } from '../../src/importers/maester-environment-context'; import { maesterEnvironmentContext } from '../../src/importers/maester-environment-context';
import type { EnvironmentSnapshot } from '../../src/domain/environment-context'; import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
@ -21,10 +21,24 @@ describe('Maester context presentation', () => {
expect(detail.inventory).toContainEqual({ label: 'Assessed host', value: '—' }); expect(detail.inventory).toContainEqual({ label: 'Assessed host', value: '—' });
}); });
it('counts distinct reported tenants and does not invent an absent tenant', () => { it('counts distinct reported tenants and does not invent an absent tenant', () => {
expect(dashboardMaesterTenantCount(snapshot)).toBe(1); expect(dashboardEntraTenantCount(snapshot)).toBe(1);
const tenant = snapshot.observations.find(observation => observation.property === 'entra.tenant_id')!; const tenant = snapshot.observations.find(observation => observation.property === 'entra.tenant_id')!;
expect(dashboardMaesterTenantCount({ ...snapshot, observations: [...snapshot.observations, tenant, { ...tenant, normalizedValue: 'tenant-2' }] })).toBe(2); expect(dashboardEntraTenantCount({ ...snapshot, observations: [...snapshot.observations, tenant, { ...tenant, normalizedValue: 'tenant-2' }] })).toBe(2);
expect(dashboardMaesterTenantCount()).toBeUndefined(); expect(dashboardEntraTenantCount()).toBeUndefined();
expect(dashboardEnvironmentDetail().toolContext.some(item => item.label.startsWith('Maester'))).toBe(false); expect(dashboardEnvironmentDetail().toolContext.some(item => item.label.startsWith('Maester'))).toBe(false);
}); });
it('counts and displays a Purple Knight for Entra ID tenant without Maester', () => {
const maesterTenant = snapshot.observations.find(observation => observation.property === 'entra.tenant_name')!;
const purpleKnightTenant = {
...maesterTenant, id: 'pk-tenant', tool: { id: 'purple-knight', name: 'Purple Knight for Entra ID' },
property: 'entra.tenant_name', subject: { type: 'entra-tenant', identity: 'synthetic.onmicrosoft.com' },
originalValue: 'synthetic.onmicrosoft.com', normalizedValue: 'synthetic.onmicrosoft.com'
};
const purpleKnightSnapshot = { ...snapshot, observations: [purpleKnightTenant] };
expect(dashboardEntraTenantCount(purpleKnightSnapshot)).toBe(1);
expect(dashboardDomainInformation(purpleKnightSnapshot)).toContainEqual({
label: 'Entra ID', value: 'synthetic.onmicrosoft.com'
});
});
}); });

View File

@ -1,6 +1,9 @@
import { createElement } from 'react'; // @vitest-environment jsdom
import { act, createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../src/domain/environment-context'; import type { EnvironmentObservation, EnvironmentSnapshot } from '../../src/domain/environment-context';
import { operatingSystemGroup, operatingSystemInventory } from '../../src/features/dashboard/operating-system-inventory'; import { operatingSystemGroup, operatingSystemInventory } from '../../src/features/dashboard/operating-system-inventory';
import { OperatingSystemInventory } from '../../src/features/dashboard/OperatingSystemInventory'; import { OperatingSystemInventory } from '../../src/features/dashboard/OperatingSystemInventory';
@ -72,10 +75,21 @@ describe('operating system inventory', () => {
const groups = operatingSystemInventory(snapshot([...os('Windows 11', 4, 1), ...os('Windows 11', 7, 1, 'other')])); const groups = operatingSystemInventory(snapshot([...os('Windows 11', 4, 1), ...os('Windows 11', 7, 1, 'other')]));
expect(groups[1].total).toBeUndefined(); expect(groups[1].total).toBeUndefined();
expect(groups[1].entries.map(entry => entry.count)).toEqual([7, 4]); expect(groups[1].entries.map(entry => entry.count)).toEqual([7, 4]);
expect(renderToStaticMarkup(createElement(OperatingSystemInventory, { groups }))).toContain('overlapping counts are not added'); expect(renderToStaticMarkup(createElement(OperatingSystemInventory, { groups }))).not.toContain('overlapping counts are not added');
expect(operatingSystemInventory(snapshot(os('Windows 11', 8, 1)))[1].total).toBe(8); expect(operatingSystemInventory(snapshot(os('Windows 11', 8, 1)))[1].total).toBe(8);
}); });
it('shows the complete quantified inventory total when another inventory reports names without counts', () => {
const groups = operatingSystemInventory(snapshot([
...os('Windows Server 2022', 40, 1), ...os('Windows Server 2025', 19, 2),
...os('Windows Server 2016', 10, 3), ...os('Windows Server 2019', 3, 4),
...os('Windows 2022', undefined, 1, 'other')
]));
expect(groups[0].total).toBe(72);
expect(groups[0].entries.find(entry => entry.name === 'Windows 2022')?.count).toBeUndefined();
});
it('shows PingCastle active, inactive, enabled and disabled counts from the exact OS source row', () => { it('shows PingCastle active, inactive, enabled and disabled counts from the exact OS source row', () => {
const groups = operatingSystemInventory(snapshot([ const groups = operatingSystemInventory(snapshot([
...os('Windows Server 2022', 12, 1), ...os('Windows Server 2022', 12, 1),
@ -101,4 +115,40 @@ describe('operating system inventory', () => {
count: 12, active: undefined, inactive: undefined, enabled: undefined, disabled: undefined count: 12, active: undefined, inactive: undefined, enabled: undefined, disabled: undefined
}); });
}); });
it('opens the table in a movable window, maximizes it and restores focus after closing', () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const groups = operatingSystemInventory(snapshot([
...os('Windows Server 2022', 12, 1),
...os('Windows Server 2025', 4, 1, 'other')
]));
act(() => root.render(createElement(OperatingSystemInventory, { groups })));
const trigger = container.querySelector<HTMLButtonElement>('[aria-label="Open Operating systems results"]');
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toBe('Results');
expect(trigger?.classList.contains('results-btn')).toBe(true);
act(() => trigger?.click());
expect(trigger?.classList.contains('is-open')).toBe(true);
const floating = document.body.querySelector<HTMLElement>('.floating-data-window');
expect(floating?.getAttribute('aria-modal')).toBe('false');
expect(floating?.textContent).toContain('Windows Server 2022');
expect(floating?.querySelector('.client-os-source')?.textContent).toBe('Inventory');
act(() => floating?.querySelector('.floating-data-window-head')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
expect(floating?.classList.contains('is-maximized')).toBe(true);
expect(floating?.querySelector('[aria-label="Restore Operating systems window"]')).not.toBeNull();
act(() => floating?.querySelector<HTMLButtonElement>('[aria-label="Close Operating systems window"]')?.click());
expect(document.body.querySelector('.floating-data-window')).toBeNull();
expect(document.activeElement).toBe(trigger);
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
}); });

View File

@ -56,6 +56,19 @@ describe('tool-independent public AI search', () => {
expect(result.query).not.toContain('PrivateTenant'); expect(result.query).not.toContain('PrivateTenant');
}); });
it('resolves the public CISA Exchange audit-retention rule without calling it Windows audit policy', () => {
const result = findingWebSearch(record(
'Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).',
{ ID: 'CISA.MS.EXO.17.3', Status: 'Failed', Tag: '["MS.EXO","CISA.MS.EXO.17.3"]' }
), 'maester');
expect(result.specificity).toBe('rule');
expect(result.query).toContain('Public rule ID: CISA.MS.EXO.17.3');
expect(result.query).toContain('https://maester.dev/docs/tests/CISA.MS.EXO.17.3/');
expect(result.query).toContain('Microsoft Purview audit log retention');
expect(result.query).not.toContain('Windows audit policy');
expect(result.query).not.toContain('exact public rule is not resolved');
});
it.each(['cis-cat', 'purple-knight', 'generic'])('adds useful fixed technical concepts for %s', tool => { it.each(['cis-cat', 'purple-knight', 'generic'])('adds useful fixed technical concepts for %s', tool => {
const result = findingWebSearch(record('SMB signing on private-host.synthetic.invalid', { Description: 'SMB signing is required for PrivateTenant', Benchmark: 'Windows Server 2022 PrivateTenant' }), tool); const result = findingWebSearch(record('SMB signing on private-host.synthetic.invalid', { Description: 'SMB signing is required for PrivateTenant', Benchmark: 'Windows Server 2022 PrivateTenant' }), tool);
expect(result.query).toContain('SMB signing requirements'); expect(result.query).toContain('SMB signing requirements');

View File

@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { createPolicyEvaluationUnifiedResult, deriveUnifiedResultFacets, queryUnifiedResultItems, selectCurrentPolicyEvaluations, type UnifiedResultProjection } from '../../src/domain/unified-result'; import { createPolicyEvaluationUnifiedResult, deriveUnifiedResultFacets, queryUnifiedResultItems, selectCurrentPolicyEvaluations, type UnifiedResultProjection } from '../../src/domain/unified-result';
import { ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser'; import { ResultRow, ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser';
import { repeatedBaselineRuns } from '../fixtures/policy-current-runs'; import { repeatedBaselineRuns } from '../fixtures/policy-current-runs';
function result(id: string, classification: NonNullable<UnifiedResultProjection['policyClassification']>): UnifiedResultProjection { function result(id: string, classification: NonNullable<UnifiedResultProjection['policyClassification']>): UnifiedResultProjection {
@ -22,6 +22,101 @@ function result(id: string, classification: NonNullable<UnifiedResultProjection[
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe('Result DB policy filter consistency', () => { describe('Result DB policy filter consistency', () => {
it('shows the concrete GPO setting and readable configured and expected values when expanded', async () => {
const item: UnifiedResultProjection = {
...result('gpo-readable', 'misconfigured'),
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
title: 'Restrict driver installation to administrators',
policyIdentityKey: 'private-policy-hash', policyBaselineId: 'private-baseline-id',
policyBaselineLabel: 'DUMMY Microsoft Security Baseline Windows Server 2022 v1',
policyActualValue: { operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
valueName: 'RestrictDriverInstallationToAdministrators', data: { kind: 'dword', value: 0 }, typeCode: 4,
principals: ['BUILTIN\\Administrators', 'DUMMY\\Domain Admins', 'DUMMY\\named.user',
'S-1-5-21-123456789-234567890-345678901-512'], gpoName: 'DUMMY – Domain Controllers Security' },
policyBaselineValue: { operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
valueName: 'RestrictDriverInstallationToAdministrators', data: { kind: 'dword', value: 1 }, typeCode: 4 },
policyComparisonRelation: 'different', policyGpoId: '{A1B2C3D4-1111-2222-3333-444455556666}',
policyNativeBackupId: '{99999999-1111-2222-3333-444455556666}'
};
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<ResultRow item={item} expanded={true} onToggle={() => undefined}/>));
expect(container.querySelector('.results-browser-title')?.textContent).toContain('Restrict driver installation to administrators');
expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent)
.toBe('registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
const detail = container.querySelector('.results-browser-row-detail')?.textContent ?? '';
expect(detail).toContain('Configured customer value');
expect(detail).toContain('Data: 0');
expect(detail).toContain('Expected baseline value');
expect(detail).toContain('Data: 1');
expect(detail).toContain('Technical setting ID: registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
expect(detail).toContain('Microsoft Security Baseline Windows Server 2022 v1');
expect(detail).toContain('BUILTIN\\Administrators');
expect(detail).toContain('[DOMAIN]\\Domain Admins');
expect(detail).toContain('[DOMAIN_PRINCIPAL_1]');
expect(detail).toContain('S-1-5-21-[DOMAIN]-512');
expect(detail).toContain('Backup comparison; effective endpoint state not verified');
expect(detail).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|99999999|private-policy-hash|private-baseline-id/);
expect(detail).not.toContain('Privacy-safe Group Policy comparison');
} finally { await act(async () => root.unmount()); }
});
it('shows useful Purple Knight rule content while withholding the affected object', async () => {
const item: UnifiedResultProjection = {
resultProjectionId: 'scanner-finding:private', sourceResultId: 'private', sourceKind: 'scanner-finding',
projectId: 'Private Company', assessmentId: 'slot', collectionRunId: 'collection', scanRunId: 'scan',
sourceTool: 'purple-knight', sourceRuleId: 'SI000055', sourceProfileId: 'purple-knight',
title: 'Privileged accounts have weak delegation settings',
description: 'Review delegation on PRIVATE\\admin-user.',
recommendation: 'Remove unconstrained delegation from the affected account.',
affectedAsset: 'PRIVATE\\admin-user', severityOriginal: 'critical', severityNormalized: 'critical',
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed',
resultType: 'exposure_finding', isIoe: true, isDeviation: true, exposureState: 'detected',
lifecycleState: 'unknown', mappingConfidence: 1, reviewStatus: 'unreviewed',
importedAt: '2026-09-22T00:00:00Z', createdAt: '2026-09-22T00:00:00Z', categories: []
};
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<ResultRow item={item} expanded={true} onToggle={() => undefined}/>));
expect(container.querySelector('.results-browser-title')?.textContent)
.toContain('Privileged accounts have weak delegation settings');
expect(container.querySelector('.results-browser-row-detail')?.textContent)
.toContain('Remove unconstrained delegation from the affected account.');
expect(container.querySelector('.results-browser-row-detail')?.textContent).toContain('Rule ID: SI000055');
expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent).toBe('SI000055');
expect(container.textContent).not.toMatch(/PRIVATE|admin-user|Private Company/);
} finally { await act(async () => root.unmount()); }
});
it('shows the native PingCastle rule ID beside the descriptive Result DB title', async () => {
const item: UnifiedResultProjection = {
resultProjectionId: 'scanner-finding:synthetic-ntlm', sourceResultId: 'synthetic-ntlm',
sourceKind: 'scanner-finding', projectId: 'dummy-project', assessmentId: 'synthetic-slot',
collectionRunId: 'synthetic-run', scanRunId: 'synthetic-run', sourceTool: 'pingcastle',
sourceRuleId: 'S-oldNtlm', sourceProfileId: 'pingcastle',
title: 'The synthetic LAN Manager Authentication Level allows NTLMv1 or LM.',
description: 'Synthetic check for legacy authentication.',
severityOriginal: 'medium', severityNormalized: 'medium', outcomeOriginal: 'Matched',
outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'exposure_finding',
isIoe: true, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1,
reviewStatus: 'unreviewed', importedAt: '2026-09-23T00:00:00Z',
createdAt: '2026-09-23T00:00:00Z', categories: []
};
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<ResultRow item={item} expanded={true} onToggle={() => undefined}/>));
expect(container.querySelector('.results-browser-title')?.textContent).toContain(item.title);
expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent).toBe('S-oldNtlm');
expect(container.querySelector('.results-browser-row-detail')?.textContent).toContain('Rule ID: S-oldNtlm');
} finally { await act(async () => root.unmount()); }
});
it('shows only the current distinct-baseline comparisons when opening GPO deviations', async () => { it('shows only the current distinct-baseline comparisons when opening GPO deviations', async () => {
const runs = repeatedBaselineRuns(); const runs = repeatedBaselineRuns();
const original = JSON.stringify(runs); const original = JSON.stringify(runs);
@ -109,7 +204,11 @@ describe('Result DB policy filter consistency', () => {
await click(deviationButton('All')); await click(deviationButton('All'));
// The status facet excludes its own filter to keep alternative statuses reachable. // The status facet excludes its own filter to keep alternative statuses reachable.
await click(checkbox('Result Status', 'Deviation')); await click(checkbox('Result Status', 'Deviation'));
expect(titles()).toEqual(['Synthetic fail-a', 'Synthetic fail-b', 'Synthetic pass']); expect(titles()).toEqual([
'Synthetic fail-a',
'Synthetic fail-b',
'Synthetic pass'
]);
await click(deviationButton('Yes')); await click(deviationButton('Yes'));
expect(statusOptions()).toEqual([['Deviation', 2], ['Passed', 0]]); expect(statusOptions()).toEqual([['Deviation', 2], ['Passed', 0]]);
expect(checkbox('Result Status', 'Passed').checked).toBe(true); expect(checkbox('Result Status', 'Passed').checked).toBe(true);

View File

@ -50,7 +50,7 @@ describe('Maester variant and context coverage', () => {
expect(model.environmentObservations).toContainEqual(expect.objectContaining({ property: 'maester.tests.actual_total', normalizedValue: 6, attributes: expect.objectContaining({ derived: true }) })); expect(model.environmentObservations).toContainEqual(expect.objectContaining({ property: 'maester.tests.actual_total', normalizedValue: 6, attributes: expect.objectContaining({ derived: true }) }));
expect(model.environmentObservations).toContainEqual(expect.objectContaining({ property: 'maester.source.LoadedModules', originalValue: [], state: 'empty' })); expect(model.environmentObservations).toContainEqual(expect.objectContaining({ property: 'maester.source.LoadedModules', originalValue: [], state: 'empty' }));
const projection = canonicalModelToDataset(model)!; const projection = canonicalModelToDataset(model)!;
const failed = projection.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.fields.some(field => field.label === 'ID' && field.value === 'MT.9901'))!; const failed = projection.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.fields.some(field => field.label === 'Source Rule ID' && field.value === 'MT.9901'))!;
expect(resultMessageFor(failed, 'maester')).toBe('<p>Synthetic evidence</p>'); expect(resultMessageFor(failed, 'maester')).toBe('<p>Synthetic evidence</p>');
expect(resultStatusFieldFor(failed, 'maester')?.value).toBe('Failed'); expect(resultStatusFieldFor(failed, 'maester')?.value).toBe('Failed');
} }

View File

@ -101,7 +101,7 @@ describe('PurpleKnight full-report integrity', () => {
const model = canonical(imported); const model = canonical(imported);
expect(model.findingOccurrences[0].originalFields.find(field => field.label === 'Source ResIndicator.Future')?.value).toEqual(indicators[0].ResIndicator.Future); expect(model.findingOccurrences[0].originalFields.find(field => field.label === 'Source ResIndicator.Future')?.value).toEqual(indicators[0].ResIndicator.Future);
const work = canonicalModelToDataset(model)!; const work = canonicalModelToDataset(model)!;
const record = work.sheets.find(sheet => !sheet.isProjectSheet && sheet.analysis.records.some(record => record.fields.some(field => field.label === 'ShortName')))?.analysis.records[0]; const record = work.sheets.find(sheet => !sheet.isProjectSheet && sheet.analysis.records.some(record => record.fields.some(field => field.label === 'Source Rule ID')))?.analysis.records[0];
expect(record?.fields.find(field => field.label === 'Status')?.value).toBe('IOE Found'); expect(record?.fields.find(field => field.label === 'Status')?.value).toBe('IOE Found');
expect(record?.fields.find(field => field.label === 'Category')?.value).toBe('Synthetic'); expect(record?.fields.find(field => field.label === 'Category')?.value).toBe('Synthetic');
}); });
@ -185,6 +185,7 @@ describe('PurpleKnight full-report integrity', () => {
it('keeps old imports compatible and recommends an explicit reimport', () => { it('keeps old imports compatible and recommends an explicit reimport', () => {
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.2.0',mappingVersion:'1.1.0'})).toBe('reimport-recommended'); expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.2.0',mappingVersion:'1.1.0'})).toBe('reimport-recommended');
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.3.0',mappingVersion:'1.1.0'})).toBe('current'); expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.3.0',mappingVersion:'1.1.0'})).toBe('reimport-recommended');
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.4.0',mappingVersion:'1.1.0'})).toBe('current');
}); });
}); });

View File

@ -7,8 +7,21 @@ import { confirmEntryMappings, previewEntryFromDataset } from '../../src/feature
import { createCanonicalImportBatch } from '../../src/importers/canonical-import'; import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { importFile } from '../../src/importers/importer'; import { importFile } from '../../src/importers/importer';
import { importProfiles } from '../../src/profiles/profile-catalog'; import { importProfiles } from '../../src/profiles/profile-catalog';
import { canonicalSecurityScores } from '../../src/scoring/canonical-security-score';
const FILE_NAME = 'Security_Assessment_Report_synthetic.example_(Assessment_summary).csv'; const FILE_NAME = 'Security_Assessment_Report_synthetic.example_(Assessment_summary).csv';
const ENTRA_SUMMARY = `Generated date,2026-07-08 09:01
Selected indicators,56
Entra ID: scan duration,00:00:10
Entra ID: tenant,synthetic.onmicrosoft.com
Entra ID: application ID,00000000-0000-0000-0000-000000000000
Entra ID: security posture score,64
Entra ID category score: Entra ID,61
Entra ID category score: Hybrid,70
Entra ID: Evaluated,56
Entra ID: IOEs found,19
Entra ID: Passed,37
`;
async function importSummary(content = summaryFixture, fileName = FILE_NAME) { async function importSummary(content = summaryFixture, fileName = FILE_NAME) {
return importFile(new File([content], fileName, { type: 'text/csv' }), { profiles: importProfiles }); return importFile(new File([content], fileName, { type: 'text/csv' }), { profiles: importProfiles });
@ -68,11 +81,42 @@ describe('Purple Knight assessment summary source extraction', () => {
assertCanonicalAssessmentModel(model); assertCanonicalAssessmentModel(model);
expect(model.findingOccurrences).toHaveLength(0); expect(model.findingOccurrences).toHaveLength(0);
expect(model.scanRuns[0].tool).toEqual({ id: 'purple-knight', name: 'Purple Knight for Active Directory' });
expect(model.scanRuns[0].tool.id).not.toBe('maester');
expect(model.scanRuns[0].tool.name).not.toMatch(/maester|entra/i);
expect(model.environmentObservations?.some(item => item.tool.id === 'maester')).toBe(false);
expect(model.environmentObservations?.some(item => item.property.startsWith('maester.') || item.property.startsWith('entra.'))).toBe(false);
expect(model.environmentObservations?.every(item => item.tool.id === 'purple-knight')).toBe(true); expect(model.environmentObservations?.every(item => item.tool.id === 'purple-knight')).toBe(true);
expect(model.environmentObservations?.find(item => item.property === 'purple_knight.ad.security_posture_score')?.normalizedValue).toBe(71); expect(model.environmentObservations?.find(item => item.property === 'purple_knight.ad.security_posture_score')?.normalizedValue).toBe(71);
expect(model.reportSections?.map(section => section.sectionKey)).toContain('purple-knight-assessment-summary'); expect(model.reportSections?.map(section => section.sectionKey)).toContain('purple-knight-assessment-summary');
}); });
it('recognizes Purple Knight for Entra ID, retains its tenant and labels the scan scope', async () => {
const fileName = 'Security_Assessment_Report_synthetic.example_(Assessment_summary).csv';
const imported = await importSummary(ENTRA_SUMMARY, fileName);
const observations = imported.extraction?.environmentObservations;
expect(imported.profileId).toBe('purple-knight');
expect(observationValue(observations, 'entra.tenant_name')).toMatchObject({
normalizedValue: 'synthetic.onmicrosoft.com', subject: {
type: 'entra-tenant', identity: 'synthetic.onmicrosoft.com'
}
});
expect(observationValue(observations, 'purple_knight.entra.security_posture_score')?.normalizedValue).toBe(64);
expect(observationValue(observations, 'purple_knight.entra.category_score.entra_id')?.normalizedValue).toBe(61);
const preview = previewEntryFromDataset(imported, {
sha256: fileName.padEnd(64, '0').slice(0, 64), fileName, sizeBytes: ENTRA_SUMMARY.length, lastModified: 0
});
const model = createCanonicalImportBatch('entra-summary', [confirmEntryMappings(preview, importProfiles)], '2026-09-22T08:00:00.000Z').model;
expect(model.scanRuns[0].tool).toMatchObject({ id: 'purple-knight', name: 'Purple Knight for Entra ID' });
expect(model.importFiles[0].detectedTool.name).toBe('Purple Knight for Entra ID');
expect(canonicalSecurityScores([model])[0]).toMatchObject({
toolId: 'purple-knight', toolName: 'Purple Knight for Entra ID', method: 'native',
score: 64, nativeProperty: 'purple_knight.entra.security_posture_score'
});
});
it('leaves an unrelated two-column table to the generic extractor', async () => { it('leaves an unrelated two-column table to the generic extractor', async () => {
const imported = await importFile( const imported = await importFile(
new File(['Metric,Value\nUptime,12\nOwner,Team A\n'], 'inventory.csv', { type: 'text/csv' }), new File(['Metric,Value\nUptime,12\nOwner,Team A\n'], 'inventory.csv', { type: 'text/csv' }),

View File

@ -93,9 +93,9 @@ describe('script-embedded scanner HTML source extractors', () => {
).model; ).model;
const dataset = canonicalModelToDataset(model)!; const dataset = canonicalModelToDataset(model)!;
const mainSheet = dataset.sheets.find(sheet => sheet.analysis.records.some(record => const mainSheet = dataset.sheets.find(sheet => sheet.analysis.records.some(record =>
record.fields.some(field => field.label === 'ShortName' && field.value === 'SI900001')))!; record.fields.some(field => field.label === 'Source Rule ID' && field.value === 'SI900001')))!;
const record = mainSheet.analysis.records.find(item => const record = mainSheet.analysis.records.find(item =>
item.fields.some(field => field.label === 'ShortName' && field.value === 'SI900001'))!; item.fields.some(field => field.label === 'Source Rule ID' && field.value === 'SI900001'))!;
const linked = linkResultSheets(record, dataset.sheets, mainSheet.id); const linked = linkResultSheets(record, dataset.sheets, mainSheet.id);
expect(linked).toHaveLength(1); expect(linked).toHaveLength(1);

View File

@ -27,13 +27,13 @@ const assessment: Assessment = {
updatedAt: timestamp updatedAt: timestamp
}; };
function run(id: string, sequence: number, toolId: string): ScanRun { function run(id: string, sequence: number, toolId: string, toolName = toolId): ScanRun {
return { return {
id, id,
assessmentId: assessment.id, assessmentId: assessment.id,
sequence, sequence,
label: id, label: id,
tool: { id: toolId, name: toolId }, tool: { id: toolId, name: toolName },
importedAt: timestamp importedAt: timestamp
}; };
} }
@ -128,6 +128,54 @@ async function commit(
} }
describe('Unified result read model', () => { describe('Unified result read model', () => {
it('shows the native public PingCastle rule ID and report title through the normal Result DB query', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject(project);
await repository.saveAssessment(assessment);
const scanRun = run('run-pingcastle-ntlm', 1, 'pingcastle');
const finding = {
...definition('definition-pingcastle-ntlm', 'pingcastle:S-oldNtlm', 'Anomalies'),
title: 'The LAN Manager Authentication Level allows the use of NTLMv1 or LM.'
};
await commit(repository, scanRun, [finding], [
occurrence('occurrence-pingcastle-ntlm', scanRun, finding.id, 'failed', 'Matched', 'S-oldNtlm')
]);
const result = await repository.queryUnifiedResults({ projectId: project.id, scanRunIds: [scanRun.id] });
expect(result).toMatchObject({
total: 1,
items: [{ title: finding.title, sourceRuleId: 'S-oldNtlm', sourceTool: 'pingcastle' }]
});
await expect(repository.getUnifiedResultById('scanner-finding:occurrence-pingcastle-ntlm'))
.resolves.toMatchObject({ title: finding.title, sourceRuleId: 'S-oldNtlm' });
});
it('keeps Purple Knight AD and Entra ID separately filterable in Result DB', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject(project);
await repository.saveAssessment(assessment);
const adRun = run('run-purple-ad', 1, 'purple-knight', 'Purple Knight for Active Directory');
const entraRun = run('run-purple-entra', 2, 'purple-knight', 'Purple Knight for Entra ID');
const combinedRun = run('run-purple-combined', 3, 'purple-knight', 'Purple Knight for Active Directory and Entra ID');
const adDefinition = definition('definition-purple-ad', 'purple-knight:ad', 'AD');
const entraDefinition = definition('definition-purple-entra', 'purple-knight:entra', 'Entra');
const combinedDefinition = definition('definition-purple-combined', 'purple-knight:combined', 'Combined');
await commit(repository, adRun, [adDefinition], [occurrence('occurrence-purple-ad', adRun, adDefinition.id, 'failed', 'Failed', 'SI000001')]);
await commit(repository, entraRun, [entraDefinition], [occurrence('occurrence-purple-entra', entraRun, entraDefinition.id, 'failed', 'Failed', 'SI000002')]);
await commit(repository, combinedRun, [combinedDefinition], [occurrence('occurrence-purple-combined', combinedRun, combinedDefinition.id, 'failed', 'Failed', 'SI000003')]);
const facets = await repository.queryUnifiedResultFacets({ deviation: true });
expect(facets.facets.find(facet => facet.id === 'sourceTool')?.values).toEqual([
{ value: 'purple-knight-ad', label: 'Purple Knight for Active Directory', count: 1 },
{ value: 'purple-knight-combined', label: 'Purple Knight for Active Directory and Entra ID', count: 1 },
{ value: 'purple-knight-entra', label: 'Purple Knight for Entra ID', count: 1 }
]);
await expect(repository.queryUnifiedResults({ sourceTool: ['purple-knight-entra'], deviation: true })).resolves.toMatchObject({
total: 1,
items: [expect.objectContaining({ sourceResultId: 'occurrence-purple-entra', sourceTool: 'purple-knight-entra', sourceToolScope: 'entra-id' })]
});
});
it('keeps occurrences separate, splits status dimensions and exposes namespaced categories and reviews', async () => { it('keeps occurrences separate, splits status dimensions and exposes namespaced categories and reviews', async () => {
const repository = new LocalAssessorRepository(); const repository = new LocalAssessorRepository();
await repository.saveProject(project); await repository.saveProject(project);
@ -170,7 +218,7 @@ describe('Unified result read model', () => {
expect(first).toMatchObject({ expect(first).toMatchObject({
sourceKind: 'scanner-finding', sourceKind: 'scanner-finding',
severityNormalized: 'high', severityNormalized: 'high',
outcomeOriginal: 'FAILED', outcomeOriginal: 'fail',
outcomeNormalized: 'fail', outcomeNormalized: 'fail',
technicalExecutionState: 'completed', technicalExecutionState: 'completed',
resultType: 'exposure_finding', resultType: 'exposure_finding',
@ -179,16 +227,16 @@ describe('Unified result read model', () => {
lifecycleState: 'unknown', lifecycleState: 'unknown',
reviewStatus: 'resolved', reviewStatus: 'resolved',
reviewDisposition: 'accepted', reviewDisposition: 'accepted',
reviewOwner: 'synthetic-owner', categories: [{ namespace: 'source-tool', categoryLabel: 'PingCastle' }]
categories: [{ namespace: 'pingcastle', categoryLabel: 'Privileged Accounts' }]
}); });
expect(first).not.toHaveProperty('reviewOwner');
const executionError = await repository.queryUnifiedResults({ const executionError = await repository.queryUnifiedResults({
outcome: ['execution_error'], outcome: ['execution_error'],
technicalExecutionState: ['failed'] technicalExecutionState: ['failed']
}); });
expect(executionError.items.map(item => item.sourceResultId)).toEqual(['occurrence-2']); expect(executionError.items.map(item => item.sourceResultId)).toEqual(['occurrence-2']);
expect(executionError.items[0].outcomeOriginal).toBe('Failed to run'); expect(executionError.items[0].outcomeOriginal).toBe('execution_error');
// Ein technischer Ausführungsfehler ist per RESULT_CONTRACT nie is_ioe=true, // Ein technischer Ausführungsfehler ist per RESULT_CONTRACT nie is_ioe=true,
// auch wenn das Werkzeug ein exposure_finding-Werkzeug ist. // auch wenn das Werkzeug ein exposure_finding-Werkzeug ist.
expect(executionError.items[0]).toMatchObject({ expect(executionError.items[0]).toMatchObject({
@ -205,8 +253,8 @@ describe('Unified result read model', () => {
const categories = await repository.listResultCategories(); const categories = await repository.listResultCategories();
expect(categories.map(category => [category.namespace, category.categoryLabel])).toEqual([ expect(categories.map(category => [category.namespace, category.categoryLabel])).toEqual([
['pingcastle', 'Privileged Accounts'], ['source-tool', 'PingCastle'],
['purple-knight', 'Account Security'] ['source-tool', 'Purple Knight']
]); ]);
const facets = await repository.queryUnifiedResultFacets({ severity: ['high'] }); const facets = await repository.queryUnifiedResultFacets({ severity: ['high'] });
@ -239,7 +287,7 @@ describe('Unified result read model', () => {
const result = await repository.getUnifiedResultById('scanner-finding:occurrence-cis-cat'); const result = await repository.getUnifiedResultById('scanner-finding:occurrence-cis-cat');
expect(result).toMatchObject({ expect(result).toMatchObject({
title: finding.title, title: "2.2.2 Ensure 'Access this computer from the network' is configured",
outcomeNormalized: 'fail', outcomeNormalized: 'fail',
resultType: 'compliance_check', resultType: 'compliance_check',
isIoe: false, isIoe: false,
@ -306,7 +354,6 @@ describe('Unified result read model', () => {
sourceProfileVersion: '1.6.0', sourceProfileVersion: '1.6.0',
mappingRuleVersion: '1.2.0+context-1.0.0', mappingRuleVersion: '1.2.0+context-1.0.0',
processingStatus: 'current', processingStatus: 'current',
importArtifactId: importFile.id,
environmentProperty: 'domain.functional_level', environmentProperty: 'domain.functional_level',
environmentSubjectType: 'active-directory-environment', environmentSubjectType: 'active-directory-environment',
environmentNormalizedValue: 2016, environmentNormalizedValue: 2016,
@ -361,11 +408,13 @@ describe('Unified result read model', () => {
occurrence('occurrence-right', scanRun, right.id, 'failed', 'Fail') occurrence('occurrence-right', scanRun, right.id, 'failed', 'Fail')
]); ]);
const results = await repository.queryUnifiedResults({ search: 'Same visible title' }); const results = await repository.queryUnifiedResults({ search: 'Security assessment finding' });
expect(results.total).toBe(2); expect(results.total).toBe(2);
expect(new Set(results.items.map(item => item.canonicalDefinitionId))).toEqual( expect(new Set(results.items.map(item => item.sourceResultId))).toEqual(
new Set(['definition-left', 'definition-right']) new Set(['occurrence-left', 'occurrence-right'])
); );
expect(JSON.stringify(results)).not.toContain('Same visible title');
expect(results.items.every(item => item.canonicalDefinitionId === undefined)).toBe(true);
}); });
it('filters and facets by mapping rule version and source profile version', async () => { it('filters and facets by mapping rule version and source profile version', async () => {

View File

@ -177,9 +177,9 @@ describe('declarative tool profiles', () => {
const profile = importProfiles.find(candidate => candidate.id === imported.profileId); const profile = importProfiles.find(candidate => candidate.id === imported.profileId);
expect(imported.profileId).toBe('purple-knight'); expect(imported.profileId).toBe('purple-knight');
// 1.3.0 covers workbook completeness and HTML context/integrity; // 1.4.0 additionally covers the Entra-ID summary and scan scope;
// canonical status normalization stays at 1.1.0. // canonical status normalization stays at 1.1.0.
expect(imported.profileVersion).toBe('1.3.0'); expect(imported.profileVersion).toBe('1.4.0');
expect(profile?.presentation.statusFilters.map(filter => filter.value)).toContain('ioes-found'); expect(profile?.presentation.statusFilters.map(filter => filter.value)).toContain('ioes-found');
expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'ioes-found')).toBe(true); expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'ioes-found')).toBe(true);
expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'deviation')).toBe(true); expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'deviation')).toBe(true);

View File

@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`); const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({ expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.5.5', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' status: 'ok', application: 'ASSESSOR', productVersion: '2.5.12', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
}); });
expect(health.headers.get('access-control-allow-origin')).toBeNull(); expect(health.headers.get('access-control-allow-origin')).toBeNull();
}); });

View File

@ -461,9 +461,11 @@ describe('baseline-driven policy evaluation', () => {
policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment
}); });
const html = renderToStaticMarkup(createElement(ResultRow, { item: unified, expanded: true, onToggle() {} })); const html = renderToStaticMarkup(createElement(ResultRow, { item: unified, expanded: true, onToggle() {} }));
expect(html.match(/Setting reference:/g)).toHaveLength(3); expect(html).toContain('Setting reference:');
expect(html).toContain('No effective device state'); expect(html).toContain('No effective device state');
// The saved per-variant values and verdicts remain available without loaded catalog metadata. expect(html).toContain('Configured Group Policy value compared with the retained baseline recommendation.');
// The saved per-variant values and verdicts remain available consistently in Work guidance
// and in the Result DB comparison without claiming effective endpoint state.
expect(buildPolicyGuidance([saved]).get(saved.results[0].id)?.description).toContain('Value comparison:'); expect(buildPolicyGuidance([saved]).get(saved.results[0].id)?.description).toContain('Value comparison:');
} }
}); });