GPO Implementierung Testbereit

This commit is contained in:
Michael 2026-09-06 20:54:33 +02:00
parent 68172e715a
commit dd92aa26d0
180 changed files with 6735 additions and 1326 deletions

View File

@ -18,9 +18,17 @@
- Treat Tauri as the current local shell and a possible optional future shell. Do not make new domain logic depend on Tauri when a platform-neutral boundary is possible.
- The target deployment is an internal browser application with a backend and PostgreSQL. The browser must not access the database directly.
## Canonical local PostgreSQL
- The single canonical local ASSESSOR datastore for this workspace is the existing Docker Compose project `assessor-2_0`, container `assessor-2_0-postgres-1`, database `assessor`, and volume `assessor-2_0_assessor-postgres-data` on `127.0.0.1:5432`.
- Every local App/Backend start must reuse that datastore. Never create an alternative ASSESSOR database, Compose project, PostgreSQL volume, or fallback port for this workspace.
- If the canonical datastore is unavailable or port `5432` is occupied by a different container, stop and report the conflict instead of spawning or selecting another database.
- `docker-compose.postgres.yml` must keep the canonical Compose project name and external volume binding. Never delete, replace, recreate, or initialize the canonical volume as an empty datastore.
- Before an approved persistence migration or destructive database operation, create and verify a backup. Unrelated Docker applications, containers, and volumes remain out of scope.
## UI typography
- Use the locally bundled Inter variable font as the single application typeface. Do not introduce view-specific display, condensed, serif, or monospace families; hierarchy comes from size and weight.
- Use the locally bundled Inter variable font as the application typeface. Do not introduce view-specific display, condensed or serif families; hierarchy comes from size and weight. Explicit user exception: paths use the shared technical-path textbox and local system monospace stack; other content remains Inter. Do not download additional fonts.
- Use only the visual weights Regular 400, Semibold 600, and Bold 700. Bold is reserved for branding and exceptional emphasis.
- Use the semantic typography roles: H1 56px/600, H2 32px/600, H3 18px/600, detail titles 20px/600, H4 16px/600, continuous prose 16px/400, structured table/Grid/Raw Data content 14px/400, labels and table headers 12px/600, metadata 12px, badges 11px, and buttons/controls 13px/600.
- Preserve normal title case for H1-H4, field labels, and buttons. Uppercase is limited to compact table headers, badges/categories, and the ASSESSOR wordmark.

View File

@ -1,3 +1,5 @@
name: assessor-2_0
services:
postgres:
image: postgres:16-alpine
@ -18,3 +20,5 @@ services:
volumes:
assessor-postgres-data:
name: assessor-2_0_assessor-postgres-data
external: true

View File

@ -1,6 +1,76 @@
# ASSESSOR – Umsetzungsplan
Status: lebendes Projektdokument
GPO-Kachelbadges 2026-09-06 (Phase 7): TileCard verwendet jetzt dieselbe GPO-Kategorieableitung wie der FindingReader und ergaenzt Scope. Gilt auch fuer gemischte Workspaces; doppelte Kategorien/Scopes werden vermieden, andere Scanner bleiben unveraendert. Bestehende Badge-Matrix und unbegrenzte normale Kacheldarstellung werden weiterverwendet. Vier neue synthetische Regressionen; 962 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Keine Daten-/Bewertungsaenderung, kein Reimport, keine Realdateninspektion oder visuelle Live-Abnahme; Phasenstatus unveraendert.
GPO-Duplicate-Results 2026-09-06 (Phase 7): Duplicates nutzen analog zu Conflicts den bestehenden Results-Button und dieselbe skalierbare Tabellenansicht. Eine Tabelle zeigt den gemeinsamen Originalwert, Typ und Quellenanzahl; eine zweite jede Quelle mit Source path, Backup und Record. Einstellung, Scope, Registry-Pfad und Value name stehen im Kontext. Keine Konflikt-/Gewinnermarkierung bei gleichen Werten. Der Reader zeigt eine kurze Duplicate-Zusammenfassung statt wiederholter Werte/Sources. Gemeinsame begrenzte, inerte Leseprojektion mit eindeutig getrennten Envelopes und Whitespace-Erhaltung; bei unvollstaendiger/mehrdeutiger Evidenz bleibt die bisherige Darstellung erhalten. Bewertung, Persistenz und Originaldaten unveraendert; kein Reimport erforderlich, bestehende Workspaces neu oeffnen. Sieben neue synthetische Regressionen inklusive 2.300 Quellen, Buttonaktivierung, Escaping, Fallback und unveraenderten Konflikten. 958 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Keine Realdateninspektion oder visuelle Live-Abnahme; Phasenstatus unveraendert.
Tooluebergreifende AI search und GPO-Badges 2026-09-06 (Phase 7): FindingReader bietet die geschuetzte Google-AI-Suche fuer alle Scan-Tools und gemischte Workspaces an. Quellenbelegter lokaler Public-Identifier-Snapshot (67 Registryziele, 45 Rechte, 19 ASR-, 161 Maester- und 51 PingCastle-Kennungen) plus native System-Access-Einstellungen und feste Fachbegriffe liefern konkrete Identitaet, Konfigurationsort/Referenz, sicheren Benchmarkkontext und begrenzte numerische/boolesche Sollziele. Keine freie Weitergabe von Berichtstexten, Kundenwerten, Namen, Identitaeten oder Quellpfaden. Fehlende Katalogtreffer bleiben ausdruecklich Topic-Suche; keine Vollstaendigkeits-, Wirksamkeits- oder Datenschutzpauschalzusage. Vorhandene GPO-Sektionen erzeugen zusaetzliche System-Access-/Privilege-Rights-/Registry-Values- und Detailbadges; Registry/Software/System sind strukturelle Fallbacks hinter konkreten Produktfamilien. Keine Bewertungs-/Persistenzaenderung oder Reimporte. Sicherheitsmodell und Public-AI-Search-Vertrag mit Quellenpflege/Grenzen ergaenzt; historische Entscheidungen bleiben erhalten. 951 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Nur synthetische Inhaltspruefungen, keine Kundendateninspektion oder Google-Abfrage mit Findingdaten. Phasenstatus unveraendert.
GPO-Kachelumbruch 2026-09-06 (Phase 7): Lange Registry- und Policy-Pfade in GPO-Work-Kacheln umbrechen innerhalb der verfuegbaren Titelspalte und koennen dadurch weder die Bookmark-Aktion noch benachbarte Kacheln ueberdecken. Die Regel ist ueber die bestehende Toolidentitaet gezielt auf `group-policy` begrenzt; normale Findingtitel behalten ihren wortbasierten Umbruch. 42 fokussierte synthetische Tests, Designpruefung und Client-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Pfaddarstellung 2026-09-06 (Phase 7): Gemeinsame inerte PathText-Darstellung fuer Finding-/Reporttexte, GPO-Werte/Sources/Remediation und Results-Tabellen samt Kontext. Pfade erhalten einen dezenten Rahmen mit themegebundener Inset-Flaeche, vollstaendigem Umbruch und lokaler System-Monospace-Schrift. Ausdrueckliche Nutzerkorrektur zur bisherigen reinen Inter-Vorgabe: nur Pfade nutzen --font-path, Labels und sonstige Inhalte bleiben Inter; AGENTS.md entsprechend praezisiert. Keine Links, Dateizugriffe, Fontdownloads oder Aenderungen an Originalwerten/Vergleichen. Zwoelf synthetische Regressionen pruefen Pfadtypen, Labels, Escaping, Suchmarkierung, lange Pfade und Stilkontrakt. 923 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung sowie Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Keine Realdateninspektion oder visuelle Live-Abnahme; Phasenstatus unveraendert.
GPO-Conflict-Results 2026-09-06 (Phase 7): Kundenkonflikte mit mehreren belegten Wertvarianten nutzen den vorhandenen Results-Button samt verschiebbarem/skalierbarem Ergebnisfenster und gemeinsamer Tabelle. Gleiche typisierte Werte werden zu Varianten gruppiert, unterschiedliche Value-/Type-Spalten mit dem gemeinsamen Warning-Akzent markiert; keine Variante wird als Gewinner oder Baseline-Sollwert behauptet. Gemeinsame Einstellung/Scope/Pfad stehen einmal im Results-Kontext, eine zweite Tabelle bindet jede Source an Variante, Originalpfad, Backup und Record. Der Reader zeigt eine kurze Konfliktzusammenfassung statt wiederholter Kundenwerte/Sources; ohne gueltige strukturierte Details bleibt die bisherige Evidenzdarstellung erhalten. Inerte, begrenzte JSON-Leseprojektion mit Whitespace-Erhaltung, keine Migration/Neubewertung oder Originaldatenmutation. Acht synthetische Regressionen inklusive Typkonflikten, HTML-Inertheit, aktivem Results-Button, Fallback und 2.300 Quellen; 911 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Keine Realdateninspektion oder visuelle Live-Abnahme; Phasenstatus unveraendert.
GPO-Misconfiguration-Fokus 2026-09-06 (Phase 7/13B, ausdrueckliche Nutzerkorrektur): Deviation enthaelt fuer GPOs nur gesetzte Kundenwerte mit expliziter vorhandener Baseline und deterministischer Abweichung. Fehlende Einstellungen werden als Not configured/informational separat angezeigt, nicht als Fail/Deviation; Conflicts und Duplicates bleiben eigene Filter ohne Deviation-Zaehlung. Der neue reine Lesekontrakt gilt fuer Work, Dashboardfilter, Result-DB-Vergleichsprojektion und bereits materialisierte Factory-Ergebnisse (Rueckbindung an deren gespeicherten Vergleich, ohne fehlende Evidenz Fail zu behaupten). Gemischte Scanner/GPO-Profile behalten die separaten Filter; Scanner-Semantik unveraendert. GPO Analysis trennt Misconfigured/Not configured und zaehlt betroffene Settings sowie GPO-Backupinstanzen dedupliziert; alte Historienzaehler sind ausdruecklich als gespeicherte Mismatches einschliesslich fehlender Werte bezeichnet. Work-Projektion 1.2.0, Result-DB-Praesentation 1.1.0; keine Migration, Reimporte oder Mutation gespeicherter Evaluationen/Originale. Ersetzt die bisherige fehlende-Werte-als-Fail-Entscheidung; Vergleichs-/Resultvertrag angepasst. 903 Tests bestanden, 8 optionale PostgreSQL-Integrationstests uebersprungen, Local-only-/Designpruefung sowie Client-/Serverbuild erfolgreich. Laufendes Entwicklungsbackend per inhaltsfreiem Health-/Buildhash-Abgleich aktuell; keine Kundendateninspektion oder Live-Inhaltsabnahme. Phasenstatus unveraendert.
AI-Suchkontext 2026-09-06 (Phase 7): Auf Nutzerwunsch ergaenzt der Suchlink Scope, exakt freigegebene Baselinebezeichnungen und feste Vergleichsszenarien. Bekannte Audit-Abhilfe fuer System Integrity/Logoff wird aus eindeutigen booleschen Sollflags, Expected/Must include und passendem Titel/Scope aufgebaut; keine freie Textweitergabe. Description-Platzhalter und unverifizierte importierte Beschreibungen/Abhilfetexte bleiben ausgeschlossen. Sicherheitsmodell dokumentiert die eng begrenzte Freigabe und verbleibende Google-IP-/Kontodatenverarbeitung ohne DSGVO-Pauschalzusage. Sechs neue synthetische Datenschutz-/Kontexttests; 898 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Hinweis). Keine Kundendateninspektion, Suchausfuehrung mit Findingdaten oder Datenmutation. Phasenstatus unveraendert.
AppLocker-Wertdarstellung 2026-09-06 (Phase 7): AppLocker-XML erscheint als lesbare Regelfelder statt Roh-XML/Codeblock in Kundenwert, Baselinewert, abgeleiteter Remediation und Sources. Bedingungen, Ausnahmen, Versionsgrenzen, IDs und unbekannte Erweiterungen bleiben erhalten. Begrenzter SAX-Formatter in der Praesentationsschicht; fehlerhafte/unsupported Werte bleiben escapeter Originaltext ohne aktive Links oder HTML. Vergleich, Persistenz und Originaldaten unveraendert. Sieben neue synthetische Regressionen, bestehende Reader-Pruefung angepasst; 892 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Bestehender Bundle-Groessenhinweis; keine Realdateninspektion oder visuelle Live-Abnahme. Phasenstatus unveraendert.
Remediation-Hervorhebung 2026-09-06 (Phase 7): Remediation-Abschnitte im FindingReader sind auf Nutzerwunsch mit derselben dezenten Gruenflaeche und Akzentlinie wie Expected baseline value hinterlegt. Gemeinsamer Success-Token fuer beide Themes; Beschreibung, Sources, Inhalte und Bewertung bleiben unveraendert. Synthetische Renderingregression; 885 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Datenmutation; Phasenstatus unveraendert.
Praezise Google-AI-Suche 2026-09-06 (Phase 7): Der bisherige Web-search-Button heisst AI search und verwendet einen Google-AI-Mode-Deep-Link. Die oeffentliche Begriffsliste erkennt jetzt zwoelf konkrete Firewall-Einstellungen und deren Domain-/Private-/Public-Profil sowie feste Microsoft-Produktversionen; doppelte Oberbegriffe wie Windows Windows Firewall entfallen. Der Prompt fragt nach Sicherheitszweck, Konfigurationsort und versionsabhaengiger Microsoft-Baselineempfehlung mit offiziellen Quellen. Keine rohe Titel-/Pfad-/Wertweitergabe; unbekannte Einstellungsnamen bleiben bei einem allgemeinen Thema. Referrer-/Opener-Schutz und expliziter Klick bleiben unveraendert, Sicherheitsmodell fortgeschrieben. Google-Dokumentation bestaetigt den allgemein verfuegbaren AI Mode, waehrend Zusatzfunktionen/Limits/Kontoverfuegbarkeit anbieterabhaengig sind; keine Garantie fuer externe Antworten. 884 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Nur synthetische Datenschutz-/URL-/UI-Tests; ein oeffentlicher Deep-Link-Abruf war ueber das Recherchewerkzeug nicht verfuegbar, keine Live-Abnahme im Google-Konto. Keine Kundendaten oder Bewertungsaenderungen; Phasenstatus unveraendert.
GPO-Sources-Darstellung 2026-09-06 (Phase 7): Sources wird aus den vorhandenen strukturierten Beobachtungen als getrennte, nummerierte Quellbloecke mit Source path, Scope, Registry path, Value name, Value, lesbarem Registrytyp und Operation projiziert. Backup-/Recordreferenzen und optionale Targets/Secure-Angaben bleiben erhalten; keine kompakte JSON-Kette fuer Registrywerte. Native Policywerte werden lesbar formatiert. Der Reader rendert Sources als escapeten Text mit Zeilenumbruechen und Pfadumbruch, nicht als HTML oder externe Links. Originaldaten und Vergleich bleiben unveraendert. Vier synthetische Regressionen einschliesslich Nullwert, mehreren/2.300 Quellen, Auditflags und inerten Pfaden; 880 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Realdateninspektion; Phasenstatus unveraendert.
GPO-Beschreibung, Abhilfe und sichere Websuche 2026-09-06 (Phase 7/13B): Work laedt zu den ausgewaehlten Evaluationen einmal je referenzierter Baseline die vorhandenen Analysen ueber die bestehende API. Originale ADML-Erklaertexte werden ausschliesslich ueber die exakte Analysis-/Baseline-/Catalog-/Policy-/Sprachdateizuordnung aufgeloest (Englisch bevorzugt); fehlende oder widerspruechliche Texte werden nicht erfunden. Description source bleibt unter Additional Information. Remediation wird als Einstellungsziel und vorhandener Baseline-Sollwert mit Vergleichsoperator abgeleitet, nicht als behauptete Microsoft-Schrittanleitung; keine Abhilfe gegen leere oder widerspruechliche Vorgaben. Reine Leseprojektion fuer vorhandene Evaluationen, keine Reimporte, Neuauswertungen, Persistenz- oder Schemaaenderungen. Dokumentationsfehler blockieren Work nicht. Der Button Web search oeffnet erst bei Klick eine Google-Suche auf Microsoft Learn; Queries bestehen ausschliesslich aus fest freigegebenen oeffentlichen Begriffen (bekannte Einstellungen oder allgemeines Fachthema), niemals aus importierten Titeln, Kundendaten, Pfaden oder Werten. Referrer/Opener sind gesperrt, Beschreibung und Abhilfe bleiben inerte Textdarstellung. Die explizit freigegebene externe Navigation samt Grenzen ist im GPO-Bedrohungsmodell dokumentiert; Sentinal diente nur als UI-Referenz, dessen direkte Titelweitergabe wurde nicht uebernommen. 876 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Ausschliesslich synthetische Tests, keine Realdateninspektion und kein Test-Suchaufruf. Phase 7 bleibt abgeschlossen, Phase 13B in Arbeit.
Pass-Farbe 2026-09-06 (Phase 7, ausdrueckliche Nutzerkorrektur): Der gemeinsame Outcome-Token fuer Pass/Passed ist in beiden Themes gruen statt grau. Dies ersetzt die bisherige bewusste Gruen-Ausnahme fuer bestandene Ergebnisse; Severityfarben und fachliche Statusnormalisierung bleiben unveraendert. Liste, FindingReader und andere gemeinsame Outcome-Anzeigen nutzen denselben Token. Synthetische Regression prueft mindestens 4,5:1 Textkontrast auf Panel-, erhoehten, aktiven und ausgewaehlten Listenflaechen beider Themes. 866 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Datenmutation, keine Live-Inhaltsinspektion; Phasenstatus unveraendert.
GPO-Scope-Badge 2026-09-06 (Phase 7): Der vorhandene Scope erscheint zusaetzlich als kompaktes Badge im FindingReader; das Scope-Feld in der Reportansicht bleibt unveraendert erhalten. Fehlende/leere Werte erzeugen kein Badge. Drei synthetische Regressionen fuer computer, user und fehlenden Scope; 864 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Daten- oder Bewertungsaenderung; Phasenstatus unveraendert.
GPO-Kategorie-Badges 2026-09-06 (Phase 7): Der FindingReader zeigt neben GPOs kurze abgeleitete Produkt-/Bereichsbadges: Windows, Office, Edge sowie Audit, Defender, Firewall, BitLocker, LAPS oder allgemein Security. Die reine Praesentationszuordnung verwendet Baselinebezeichnung, expliziten Registry-Pfad und Policytyp/-sektion; unbekannte Einstellungen erhalten keine erratene Spezialkategorie. Baseline-only-Auditvergleiche bleiben auch ohne Kundenwert als Audit erkennbar. Produktbadges beschreiben den Benchmark, nicht die nachgewiesene Zielanwendbarkeit; die Bereichstaxonomie ist eine ASSESSOR-Anzeige, keine neue Microsoft-Bewertung. Keine Aenderung an Outcomes, Persistenz, API oder Quelldaten. 861 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich (bestehender Bundle-Groessenhinweis). Nur synthetische Tests; keine Realdateninspektion oder visuelle Live-Abnahme. Phase 7 bleibt abgeschlossen.
GPO-Finding-Ueberschrift 2026-09-06 (Phase 7, Produktversion 2.5.4): Der normalisierte `Value Name` einer GPO-Registry-Einstellung bildet jetzt die Ueberschrift des Finding-Abschnitts. Derselbe Wert wird im Block `Configured customer value` nicht wiederholt; konfigurierte und erwartete Werte bleiben direkt vergleichbar. Persistierte Quellwerte und Vergleichslogik bleiben unveraendert. 843 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Ausschliesslich synthetische Tests, keine Daten-, API-, Persistenz- oder Schemaaenderung. Phase 7 bleibt abgeschlossen.
GPO-Analyse-UX-Korrektur 2026-09-06 (Phase 7/13B, Produktversion 2.5.3): Der nicht abgestimmte Sammelstatus `Limited analysis` ist wieder entfernt. Ein technisch partiell abgeschlossener GPO-Import wird im Timeslot als `Imported` und im Importdetail als `Imported with review items` ausgewiesen; ohne konkrete Nutzeraktion bleibt der Client bereit und erhaelt kein automatisches Manage. Die Standardansicht der GPO Analysis zeigt jetzt ausschliesslich Importumfang, Baselineergebnisse und konkret benannte Importprobleme; Abweichungen werden als einzelne Einstellungsvergleiche erklaert. Workflow, Historie, Review-Codes und Processing-Aktionen bleiben fuer Diagnosezwecke unter `Technical details` beziehungsweise `Processing controls` aufklappbar. Im Work-Finding stehen der konfigurierte Kundenwert und der erwartete Baselinewert direkt untereinander, werden fuer lesbare Anzeige formatiert und bei Abweichungen rot/gruen markiert; persistierte Quellwerte bleiben unveraendert. 843 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Ausschliesslich synthetische Tests, keine Daten-, API-, Persistenz- oder Schemaaenderung. Phase 7 bleibt abgeschlossen, Phase 13B in Arbeit. Diese Nutzerkorrektur ersetzt die unmittelbar vorherige Statusentscheidung fuer `Limited analysis`.
GPO-only-Timeslot-Scope 2026-09-06 (Phase 7/13B): Alle Clientansichten erkennen einen ausgewaehlten Timeslot nun zentral sowohl ueber Scanner-Run-IDs als auch ueber Assessment-IDs. Damit funktionieren reine GPO-Timeslots in Client Info, Work, Result DB und Pathfinder, ohne faelschlich `No timeslot selected` anzuzeigen oder einen Scannerlauf aus einem anderen Stand einzumischen; Datahub und DB Structure bleiben wie bisher unabhaengig vom geladenen Scannertyp. Result-DB-Laden und -Export verwenden denselben projekt- und assessmentgebundenen Scope. 25 fokussierte synthetische Tests, Designpruefung und Client-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen und Phase 13B in Arbeit.
Status und Aenderungsprotokoll 2026-09-06 (Phase 7, Produktversion 2.5.2): `Partial import` beziehungsweise `Partially processed` heisst jetzt `Limited analysis`. Abgeschlossene eingeschraenkte Analysen und laufende GPO-Jobs sind eigene gelbe Informationszustaende; sie loesen weder automatisches Manage noch eine automatische Expansion als Handlungsaufforderung aus. Fehler/Recovery und notwendige Scanner-Reimporte behalten Vorrang und automatisches Manage; die manuelle Verwaltung ausgewaehlter Clients bleibt verfuegbar. Diese Nutzerkorrektur ersetzt die bisherige Gleichsetzung aller nicht erfolgreichen GPO-Zustaende mit Handlungsbedarf. Analysegrenzen bleiben sichtbar und werden nicht als vollstaendige Auswertung bezeichnet. 839 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Ausschliesslich synthetische Tests; keine fachliche Pipeline-, Daten- oder Schemaaenderung. Phase 7 bleibt abgeschlossen, Phase 13B in Arbeit.
UI-Sprache 2026-09-06 (Phase 7, verbindliche Produktvorgabe): Saemtliche nutzerseitig sichtbaren Texte in ASSESSOR werden auf Englisch formuliert. Das gilt insbesondere fuer Navigation, Titel, Labels, Buttons, Status-, Lade-, Leer-, Fehler-, Bestaetigungs- und Dialogtexte sowie neu hinzukommende Funktionen. Deutsche Texte duerfen nur in interner Dokumentation, Kommentaren oder ausdruecklich angeforderten Exportinhalten vorkommen. Der verbliebene Ladehinweis der Data-Hub-Historie lautet nun `Loading clients …`. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Panel-Ladezustand 2026-09-06 (Phase 7): Inhaltsbereiche mit initialem, asynchronem Laden verwenden nun einen gemeinsamen, markenfreien Ladezustand: Der jeweils konkrete Ladetext steht mit der aus den Processing-Pop-ups bekannten quadratischen Ladeanimation unmittelbar daneben mittig im verfuegbaren Panelbereich. Client Info, DB Structure, Result DB, Data-Hub-Historie, Timeslot-Liste und Policy Analysis sind angebunden; kleinere eingebettete Bereiche verwenden dieselbe Darstellung kompakter. Fehler-, Leer- und laufende Operationsdialoge bleiben fachlich getrennt. 835 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
GPO-Importkorrektur 2026-09-06 (Phase 13B): Byte-identische Policydateien an unterschiedlichen Pfaden erhalten nun unterschiedliche, weiterhin deterministische Observation-IDs, weil der exakte Quellartefaktbezug Bestandteil der ID ist. Damit wird ein gueltiges Kundenbackup nicht mehr als `duplicate-observation` atomar abgelehnt. Der automatische Pfad verlangt bei einem reinen GPO-Backup keinen zusaetzlichen Betriebssystemkontext mehr, sondern wertet alle importierten, als `eligible` klassifizierten Microsoft-Baselines als getrennte Benchmarks aus; erkannte DC-Kontexte bleiben als Evidenz erhalten. Ein gemeinsam hochgeladener `selected-gpos`-Satz bleibt auch bei fehlenden oder gemischten Domainmetadaten ein einzelner, bewusst partieller Snapshot statt in nicht auswertbare Einzelsnapshots zu zerfallen. Parser-/Analyzervertrag 2.2.2, Snapshotaggregation 1.1.0 und Produktversion 2.5.1 markieren die kompatible Fehlerkorrektur. Synthetische Regressionen fuer zwei identische Security-Template-Dateien an verschiedenen Pfaden, kontextfreien Auto-Vergleich, Baseline-Auswahl, gemischte Uploadmetadaten und den 2.300-GPO-Lastfall sind gruen. Inhaltsfreie technische Kontrolle am betroffenen Import: Status-API HTTP 200, kein fehlgeschlagener Job, 184 erkannte GPOs sowie 3.638 Work-Ergebnisse mit 162 Uebereinstimmungen und 3.358 Abweichungen; Quellinhalte wurden weder ausgegeben noch manuell inspiziert. Gesamtlauf: 835 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL-Migration; Phase 13B bleibt in Arbeit.
GPO-Statuspraezisierung 2026-09-06 (Phase 7/13B): Ein vollstaendig erfolgreich verarbeiteter GPO-Backup-Eintrag zeigt im Timeslot `Ready` statt des rein uploadbezogenen `Imported`. Queued, Processing, Partial import, Failed und Recovery pending bleiben unveraendert, damit technische Zwischen-, Teil- und Fehlerzustaende weiterhin sichtbar sind. Zehn fokussierte synthetische Tests und Client-Produktionsbuild erfolgreich; keine Daten-, API-, Persistenz- oder Schemaaenderung.
Branding-Korrektur 2026-09-06 (Phase 7): Die ASSESSOR-Marke verwendet in Sidebar, leerem Arbeitsbereich, Favicon und About-Dialog ein gemeinsames PNG mit echter Transparenz; weisser Hintergrund, weisses A-Innenfeld und weisser Haken sind entfernt, nur die rote Marke bleibt. Der klickbare Brand-Trigger behaelt Position und zugaengliche About-Beschriftung, zeigt beim Hover jedoch keine Buttonflaeche. Der bestehende About-Dialog stellt die Marke mit 160 px dominant ueber den Produkt- und Processingdaten dar. Fuenf fokussierte Tests und Client-/Serverbuild erfolgreich; keine Daten-, API-, Persistenz- oder Schemaaenderung, Phase 7 bleibt abgeschlossen.
Review-Persistenz und Vergleichsplanung 2026-09-06 (Phase 8/9/10): Die FindingReader-Felder Bookmark, Status, Prioritaet, Owner/Team, Due Date, Prerequisites, Required Access/Roles, Next Step, Evidence/Acceptance Criteria, Comment und alle fuenf Marker sind ueber die bestehende Same-Origin-Review-API an das kanonische Finding-Vorkommen gebunden und werden in der ASSESSOR-Datenbank gespeichert. Aggregate und Werkzeugblatt teilen dadurch denselben Reviewstand; vorhandene Browserwerte bleiben als Rueckfallkopie erhalten und werden bei noch fehlendem Datenbankreview uebernommen. Additive JSONB-Vertragserweiterung, keine SQL-Migration und keine Realdateninspektion. Das unten in Phase 8 ausformulierte Quellen-Fortschreibungsmodell ist als spaeteres Arbeitspaket beschlossen, aber noch nicht implementiert.
GPO-Loeschkorrektur 2026-09-06 (Phase 7/13B): Jeder beendete GPO-Eintrag im Timeslot besitzt neben Evaluate beziehungsweise Retry eine eigene Delete-Aktion mit Warnungsdialog. Die bestaetigte projekt-/artefaktgebundene Mutation entfernt das ausgewaehlte GPO-Original, Processing-/Identity-Historie und alle davon abhaengigen Snapshot-, Evaluation- und Finding-Derivate; Timeslot, Environment Profile, Scannerimporte und andere GPO-Eintraege bleiben erhalten. Queue-/Running-Zustaende sind gegen paralleles Loeschen gesperrt. 829 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Ausschliesslich synthetische Loeschtests; der GPO-Pfad benoetigt keine neue Migration und hat keine kanonischen Fachdaten mutiert. Beim anschliessenden Neustart wurde die bereits im Workspace anstehende Migration 020 nach Korrektur einer kollidierenden automatisch vergebenen Constraint-Bezeichnung auf dem kanonischen 2.0-Bestand angewandt; App und PostgreSQL-Backend melden HTTP 200. Phase 7 bleibt abgeschlossen und Phase 13B in Arbeit.
UI-Textstandard 2026-09-06 (Phase 7, verbindlicher Nutzerwunsch): Funktionen mit kurzen, praegnanten Namen benennen, moeglichst ein Wort oder eine kurze Aktion. Name und Bedienelemente genuegen; keine ungefragten Erklaerabschnitte oder begleitenden Funktionsbeschreibungen. Gilt auch fuer GPO Analysis. Zusaetzliche Erlaeuterungen nur auf Nutzerwunsch; notwendige Fehler-/Sicherheitsmeldungen knapp und konkret halten. Der Timeslot-Importbereich heisst jetzt "Add Entry" ohne Beschreibung. Ersetzt die vorherige Entscheidung fuer den langen Hinzufuegen-/Erhaltungshinweis; keine Funktionsaenderung.
UI-Korrektur 2026-09-06 (Phase 7): Timeslot-Gridzeilen behalten ihre Inhaltshoehe statt lange Details am unteren Rand abzuschneiden. Die Quellenliste ist auf maximal 40vh/384px begrenzt und separat scrollbar; Importaktionen liegen ausserhalb und sind bei jedem aufgeklappten Slot unabhaengig von der Checkbox verfuegbar. 809 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Client-/Serverbuild, Local-only und Designpruefung erfolgreich. Keine Datenmutation oder visuelle Browserabnahme; Phasenstatus unveraendert.
UI-Praezisierung 2026-09-06 (Phase 7): Eintragsaktionen bleiben auch in schmalen Timeslot-Listen rechts neben dem zugehoerigen Scan/GPO-Eintrag. Importaktionen sind als "Add entry to this timeslot" mit Erhaltungshinweis gekennzeichnet; "Entire timeslot / Delete timeslot" steht separat darunter. Der bestehende Importpfad fuegt ohne replacesScanRunId einen neuen Scan hinzu; keine Persistenzaenderung. 808 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Client-/Serverbuild, Local-only und Designpruefung erfolgreich. Keine visuelle Browserabnahme; Phasenstatus unveraendert.
Praezisierung 2026-09-06 (Phase 7, Nutzerkorrektur zur Open-Freigabe): Open bleibt auf Client-Kachel und einzelnem Time-Slot deaktiviert, bis gespeicherte Scanner-Findings oder Policy-Observations fuer den Assessment-Scope nachgewiesen sind. Upload-/Jobstatus allein reicht nicht. Die vorhandene Facetten-API liefert die Ergebniszaehler; ungeklaerte/fehlgeschlagene Abfragen sperren Open. Manage/View behalten ihre Diagnosefunktion. 807 Standardtests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only/Design erfolgreich. Keine neue API oder Datenmutation; Phase 7 bleibt abgeschlossen. Diese Freigaberegel ersetzt die vorherige Oeffnung leerer GPO-Workspaces.
Korrektur 2026-09-06 (Phase 7 / 13B, ersetzt die vorherige GPO-Open-Sonderroute): Auf ausdruecklichen Nutzerwunsch oeffnet Open den Client in Client Info; ausschliesslich View fuehrt zur GPO-Analyse. GPO-only-Kacheln sind oeffenbar, der gewaehlte Assessment-Scope wird durch Workspace-Laden und Auffrischen weitergereicht und gegen automatische Scanner-Timeslot-Auswahl geschuetzt. Ohne Ergebnisse erscheint ein Client-Hinweis, alte Clientdaten werden beim Wechsel entfernt. Gemeinsame Hover-Regel fuer normale Quellenzeilenbuttons umfasst jetzt auch View in beiden Themes. Client-/Serverbuild, 14 fokussierte Tests sowie Local-only/Design erfolgreich. Gesamtlauf zuletzt 804 bestanden, 8 uebersprungen, ein anschliessend korrigierter synthetischer Test ohne Assessment-Zuordnung; betroffene Suite im fokussierten Nachlauf gruen. Fruehere Client-Open-Quelltextpruefung an neue Signatur und Whitespace angepasst. Keine Persistenz-/Backend-Aenderung oder visuelle Realdatenabnahme; Phasenstatus unveraendert.
Nachpruefung 2026-09-06 (Phase 7 / GPO-Oberflaeche Phase 13B): Client-Verarbeitungsstatus umfasst jetzt Scanner und GPOs ueber alle Timeslots. Dead-letter und Recovery markieren den Client rot mit auswahlunabhaengigem Manage; ausstehende oder partielle GPO-Verarbeitung bleibt gelb, vollstaendig erfolgreiche gruen. GPO-Status wird alle fuenf Sekunden nachgeladen; Manage expandiert auch betroffene GPO-Timeslots. 13 fokussierte synthetische Tests inklusive unselektierter Fehlerkachel, Client-/Serverbuild sowie Local-only-/Designpruefung erfolgreich. Bekannter unabhaengiger Client-Open-Quelltexttest weiterhin rot. Keine Datenmutation oder visuelle Realdatenpruefung; Phasenstatus unveraendert.
Nachpruefung 2026-09-06 (Phase 7): Retry in der GPO-Time-Slot-Zeile behaelt beim Hover die eigene Buttonflaeche und nutzt den gemeinsamen staerkeren Rand, damit es nicht mit dem Zeilenhover verschmilzt. Destruktiver Delete-Hover bleibt unveraendert. Client-/Serverbuild, Local-only und Designpruefung erfolgreich; der bekannte unabhaengige Client-Open-Quelltexttest bleibt rot. Rein lesende technische Jobpruefung: Ein GPO-Job steht nach 3/3 Versuchen wegen `gpo-metadata-size-limit` auf dead-letter; der Parser begrenzt einzelne Metadaten-XML-Dateien auf 2 MiB. Das Importlimit wurde nicht geaendert, kein Retry ausgeloest. Phase 7 bleibt abgeschlossen; keine visuelle Browserabnahme.
Nachpruefung 2026-09-06 (Phase 7 / GPO-Oberflaeche Phase 13B): Schmale Time-Slot-Quellenlisten wechseln unter 620 px Containerbreite auf mehrzeilige Eintraege; Quellenzellen koennen schrumpfen und lange Dateinamen umbrechen. Open fuehrt bei einem einzelnen GPO-Time-Slot ohne anzeigbaren Scanner zur bestehenden projektweiten GPO-Analyse, auch waehrend der Verarbeitung. Mehrfachauswahl bleibt auf vergleichbare Scanner begrenzt. Vier fokussierte Tests und Client-/Serverbuild erfolgreich; Gesamtlauf: 798 bestanden, 8 uebersprungen, ein unabhaengiger Fehler im whitespace-abhaengigen Client-Open-Quelltexttest. Kein Nachweis fuer einen erfolgreich verarbeiteten realen GPO-Job und keine visuelle Browserabnahme; Phasenstatus unveraendert. Bestehende Datenbankkonfiguration bleibt unberuehrt.
Erstellt: 2026-07-17
Zuletzt aktualisiert: 2026-08-25
Aktuelle Entwicklungsstufe: lokale React-/Tauri-Anwendung
@ -368,6 +438,8 @@ Abnahmekriterien:
Status: abgeschlossen
Verifizierte Produktversionsanzeige vom 2026-09-06: Der Markenblock zeigt die zentrale npm-Produktversion, aktuell `ASSESSOR 2.5.1`, statt des davon unabhaengigen Processingvertrags. Der gesamte Markenblock oeffnet ein kompaktes About-Popup mit Produkt-, Build-, Backend-, Import- und GPO-Komponentenversionen sowie den dezenten Credits Michael Lukas und Binh Karl. Die Health-API meldet die Backend-Produktversion, sodass Frontend- und Backendstand erkennbar sind. Version 2.5.1 ist der erste kompatible Patch nach Einfuehrung von 2.5.0 und korrigiert den GPO-Import byte-identischer Dateien an verschiedenen Pfaden. 835 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phase 7 bleibt abgeschlossen.
Verifizierte Result-DB-Filter-/Exportnachschaerfung vom 2026-09-03: Die Result DB startet und resettiert mit `Deviation = Yes`. Ein neuer `Export JSONL`-Button exportiert nicht nur die geladene Seite, sondern die vollstaendige aktuelle Filter- und Timeslot-Ergebnismenge ueber stabile Seitenabfragen. Zeile eins dokumentiert Format-/Schemaversion, Zeitpunkt, Scope, angewandte Filter und Ergebnisanzahl; danach folgt je Result genau ein vollstaendiges JSON-Objekt. Aendert sich die Ergebnismenge waehrend des Exports, wird der Download fail-closed abgebrochen. Drei fokussierte Export-/Scope-Tests und der Gesamtlauf mit 767 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Verifizierte Client-Info-Balkenformatierung vom 2026-09-03: Die wiederverwendeten Kennzahlzeilen besitzen nun identische Label-, Balken- und Wertspalten sowie eine in jeder Zeile reservierte Statuszeile. Optionale `Restricted`-Hinweise verschieben dadurch keine Nachbarzeilen mehr; Zahlen, Einheiten und Balken bleiben ueber alle gleich breiten Karten ausgerichtet. Die kompakte Infrastructure-&-Resilience-Ausnahme behaelt ihre eigenstaendige Einspaltengeometrie. 13 fokussierte Dashboard-/Kacheltests, der Gesamtlauf mit 767 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
@ -404,6 +476,10 @@ Verifizierte Client-Info-Detailkorrektur vom 2026-09-03: Minimierte Informations
Verifizierte Client-Info-Standardansicht vom 2026-09-03: Beim Oeffnen ist ausschliesslich `Assessment Status` expandiert. Die acht uebrigen Mengen-/Faktenkarten sowie `Severity Distribution` starten als vollbreite minimierte Kopfzeilen mit rechtem Dreieck und koennen weiterhin individuell geoeffnet werden. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests und der Client-/Server-Produktionsbuild sind erfolgreich. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Verifizierte Client-Info-Titelleisteninteraktion vom 2026-09-04: Bei allen Mengen-, Fakten- und Donutkarten minimiert beziehungsweise maximiert ein Klick auf eine beliebige Stelle der gesamten Titelleiste den Karteninhalt. Der bestehende zugaengliche Pfeil-Button bleibt fuer Tastaturbedienung und `aria-expanded` erhalten; die gemeinsame Hoverflaeche macht die vergroesserte Klickzone sichtbar. 13 fokussierte Dashboard-/Interaktionstests, Local-only- und Designpruefung sowie der Client-/Server-Produktionsbuild sind erfolgreich. Der Gesamtlauf bestaetigt 780 bestandene Tests; genau eine unabhaengige statische Data-Hub-Open-Regression erwartet weiterhin die alte einzeilige Quelltextformatierung der funktional vorhandenen mehrzeiligen `dashboard`-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Verifizierte DB-Structure-Neugestaltung vom 2026-09-04: Die Datenbankansicht besitzt nun eine klare Informationshierarchie aus Einordnung und Schemaaktionen, vier kompakten Kennzahlen, fachlich beschriebenen Verarbeitungslayern und progressiv aufklappbaren Tabellendetails. Tabellen zeigen Spalten, Typen, Constraints und Beziehungen in strukturierten Inter-Zeilen; `Expand all` beziehungsweise `Collapse all` steuert die gesamte Ansicht. Segmente und Kacheln verwenden die gemeinsame transparente beziehungsweise flache, eckige Designsprache ohne Legacy-Akzentkontur, Schatten oder Monospace-Sondertypografie. Responsive Regeln erhalten die vollstaendige Lesbarkeit auf schmalen Viewports. Zwölf fokussierte Layout-, Interaktions-, Inset-, Typografie- und Kachelvertragstests, Local-only- und Designpruefung sowie der Client-Produktionsbuild sind erfolgreich. Der Gesamtlauf bestaetigt 783 bestandene Tests; zwei unabhaengige Regressionen betreffen weiterhin die einzeilige Data-Hub-Quelltextformatierung und eine parallel geaenderte GPO-Evaluationserwartung, 8 optionale PostgreSQL-Tests wurden uebersprungen. Der Server-Produktionsbuild ist durch eine fachfremde parallele Syntaxaenderung in `gpo-policy-evaluation-service.ts` blockiert. Keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen.
Ziele:
- Filter aus normalisierten Rollen und tatsächlichen Datenwerten erzeugen.
@ -783,6 +859,33 @@ Abnahmekriterien:
- Unsichere Matches werden nicht automatisch als identisch gespeichert.
- Benutzerbestätigungen können als lokale Mappingregel wiederverwendet werden.
#### Geplantes Arbeitspaket: faire Vergleiche bei fehlenden Folgequellen
Ziel ist eine belastbare Fortschrittsaussage, auch wenn ein Kunde beim naechsten Timeslot nicht mehr alle bisherigen Scanner- oder GPO-Quellen liefert. Fehlende Evidenz darf weder als Verbesserung noch als Behebung erscheinen.
Verbindliches Fachmodell:
- Jede im Projekt erwartete Quelle besitzt je Timeslot genau einen Evidenzzustand: `observed-current` (im aktuellen Stand neu erhoben), `carried-forward-awaiting-revalidation` (letzter bekannter Stand, aktuell nicht erneut gemessen) oder `retired-out-of-scope` (ausdruecklich ab einem Stichtag aus dem Scope genommen).
- Fehlt eine zuvor erwartete Quelle, werden ausschliesslich ihre noch offenen Finding-/Review-Verpflichtungen fortgeschrieben. Der alte Scan, die alte GPO-Beobachtung und deren Zeitstempel werden nicht als aktuelle Messung in den neuen Timeslot kopiert; Original-Timeslot, Quelltool, letzter Beobachtungszeitpunkt und Alter bleiben sichtbar.
- Ein neuer Lauf derselben Quelle und desselben Scopes ersetzt den Fortschreibungszustand. Erst dann darf die Vergleichsengine `neu`, `weiterhin vorhanden`, `behoben` oder `wieder aufgetreten` fuer diese Quelle verifizieren.
- Das ausdrueckliche Beenden einer Quelle ist eine eigene, auditierbare Aktion mit Akteur, Wirksamkeitsdatum und Begruendung. Es loescht keine Historie und ist nicht mit dem normalen Auswahlhaekchen beziehungsweise Bookmark gleichzusetzen.
- GPO- und andere umgebungsbezogene Rohbeobachtungen bleiben als historische letzte Evidenz erhalten, werden aber ohne aktuellen Import nicht neu bewertet. Fortgeschrieben werden offene Findings und Arbeitsverpflichtungen, nicht behauptete aktuelle Konfigurationen.
Vergleich und Score werden getrennt ausgewiesen:
1. **Gemessener Fortschritt** verwendet nur die Schnittmenge der im Basis- und Ziel-Timeslot aktuell beobachteten, fachlich vergleichbaren Quellen.
2. **Gesamtexposition / offener Backlog** umfasst aktuelle Findings plus fortgeschriebene, noch nicht erneut verifizierte Findings. Fortgeschriebene offene Risiken behalten ihr Risikogewicht, damit das Weglassen eines Scans den Score nicht verbessert.
3. **Evidence Coverage / Freshness** zeigt separat, welcher Anteil aktuell gemessen, fortgeschrieben oder bewusst aus dem Scope genommen ist. Score und Abdeckung duerfen nie zu einer einzigen scheinbar praezisen Zahl verschmelzen.
Vorgesehene Abnahmekriterien:
- Ein fehlender Folgescan erzeugt weder `behoben` noch eine automatische Scoreverbesserung.
- Gemeinsam vorhandene Quellen liefern weiterhin einen normalen, reproduzierbaren Fortschrittsvergleich.
- Fortgeschriebene Findings sind optisch und maschinenlesbar von aktuell bestaetigten Findings getrennt und zeigen Quelle, Ursprungs-Timeslot und Alter.
- Ein spaeterer aktueller Scan beendet die Fortschreibung deterministisch und erzeugt den korrekten Lebenszyklus.
- `retired-out-of-scope` erfordert eine ausdrueckliche, auditierte Entscheidung; historische Findings bleiben erhalten.
- Scanner, GPOs und spaetere Tools benutzen denselben generischen Quellenzustandsvertrag, ohne toolbezogene Sonderlogik in React.
### Phase 9 – Fachliche Persistenzschnittstelle
Status: abgeschlossen
@ -816,6 +919,13 @@ Verifizierter Abschlussstand vom 2026-07-17:
- Verträge, Transaktionsgrenzen, Konfliktregeln und Backendübergang sind in docs/PERSISTENCE_BOUNDARY.md dokumentiert.
- 33 Testdateien mit 91 Tests, Local-only- und Architekturprüfung sowie Produktionsbuild laufen erfolgreich.
Nachtraeglicher verifizierter UI-Integrationsstand vom 2026-09-06:
- Die in FindingReader gepflegten Workflowdaten werden nicht mehr ausschliesslich im Browser gespeichert. Kanonische Finding-Vorkommen tragen ihre stabile Datenbankidentitaet bis in jede Work-Projektion; alle Projektionen desselben Vorkommens teilen einen Review.
- Bookmark, exakter UI-Status, kanonischer Workflowstatus, Prioritaet, Owner/Team, Due Date, Prerequisites, Required Access/Roles, Next Step, Evidence/Acceptance Criteria, Comment und die Marker Question Needed, Permission Blocked, Quick Win, External Team sowie Not Relevant werden ueber die bestehende Review-API mit optimistischer Revision persistiert.
- Vorhandene datenbankgestuetzte Reviews haben beim Laden Vorrang. Ein bisher nur lokal vorhandener Review bleibt erhalten, wird auf alle Projektionen desselben Vorkommens vereinheitlicht und beim naechsten verbundenen Laden ohne bestehenden Datenbankreview migriert. Fuer rein lokale, noch nicht kanonisch persistierte Importe bleibt die Browserablage der sichere Rueckfall.
- Die Erweiterung nutzt das vorhandene JSONB-Reviewobjekt und benoetigt keine SQL-Migration. 43 fokussierte synthetische Roundtrip-, Repository-, Projektions-, Scope- und API-Tests, 825 Standardtests, Local-only-/Designpruefung sowie Client-/Serverbuild sind erfolgreich; 8 optionale PostgreSQL-Tests wurden uebersprungen und keine realen Findinginhalte gelesen.
Abnahmekriterien:
- React führt kein SQL aus.
@ -1184,6 +1294,8 @@ Abnahmekriterien:
Status: in Arbeit
Zwischenstand 2026-09-06 (Baseline-Vollreimport): Der bestaetigte Delete-Pfad entfernt nun auch ausschliesslich reproduzierbare Policy-Evaluations- und Finding-Ketten, die die geloeschte Baselineanalyse referenzieren. Kundenimporte, GPO-Beobachtungen, Environment Profiles und Snapshotaggregationen bleiben erhalten. Damit ersetzt diese Entscheidung die fruehere Delete-Sperre fuer bereits verwendete Baselines. Vor der produktiven Loeschung wurde der kanonische PostgreSQL-Bestand als Custom-Format gesichert und der Dumpkatalog gelesen. Der letzte blockierte Baselineeintrag wurde geloescht; der Katalog ist leer und 51 GPO-Beobachtungen sind weiterhin vorhanden. Der Neuimport selbst erfolgt anschliessend durch den Benutzer.
Ziele:
- Scannerinformationen ausserhalb einzelner Findings verlustfrei und mit Quellprovenienz erfassen.
@ -1210,8 +1322,26 @@ Abnahmekriterien:
### Phase 13B – GPO-Backup- und Microsoft-Baseline-Analyse
Verifizierter Zwischenstand 2026-09-06 (Benchmark-Semantik): Evaluator 1.3.3 erzeugt `Passed` oder `Failed` ausschliesslich aus einer zur beobachteten Umgebung passend ausgewaehlten Microsoft-Security-Baseline mit vorhandenem, technisch vergleichbarem Sollwert. Ein vorhandener Sollwert ohne Kundenwert ist `Failed`; identische Werte sind `Passed`, abweichende Werte `Failed`. Leere Baselinezeilen werden nicht bewertet, nicht unterstuetzte Werte bleiben Review/Warning und werden nie zu Pass/Fail umgedeutet. Security-Template-Registrywerte behalten Typ und Wert im Vergleich. Ausschliesslich Dummy wurde neu ausgewertet: 363 Failed, 13 Passed und 8 Review/Warning; 0 Pass/Fail-Zeilen ohne Baselinewert. 831 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Phase 13B bleibt in Arbeit.
Status: in Arbeit
Verifizierter Zwischenstand 2026-09-06 (baseline-getriebener Soll-/Ist-Vergleich): Evaluator 1.3.1 startet je aktiver Microsoft-Baselineanalyse bei deren technisch aufgeloesten Empfehlungen. Ein identischer typisierter Ist-Wert wird `configured_match`/Passed, ein abweichender oder im importierten GPO-Backup fehlender Ist-Wert wird `confirmed_configured_deviation`; Einstellungen ohne Baselineempfehlung erzeugen kein Bewertungsergebnis. Mehrere Baselines werden unabhaengig ausgewertet und nicht mehr als mehrdeutige globale Betriebssystemauswahl blockiert. Konflikte, indeterminierte Werte und unvergleichbare Typen bleiben Review. Die neutrale Auswertung behauptet weder Wirksamkeit noch Severity und bleibt fuer die Finding Factory `ineligible`. Migration 020 erlaubt dafuer baseline-gebundene Ergebnisse ohne erfundenen Aggregationszustand; Work zeigt fehlende und vorhandene Vergleiche einzeln mit kurzem Baseline-Namen. Ein deduplizierter GPO-Reimport startet die aktuelle Auswertung erneut. Nach Datenbanksicherung wurde ausschliesslich der freigegebene Dummy neu ausgewertet: 3.638 Vergleiche, 55 Passed, 3.556 Abweichungen (davon 3.483 fehlende Ist-Werte) und 27 Review-Faelle. Kein anderer Mandant wurde ausgewertet. 821 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Phase 13B bleibt fuer die Aufloesung der 27 Review-Faelle, weitere Preference-CSEs und Exportdetails in Arbeit.
Verifizierter Zwischenstand 2026-09-06 (direkte Work-Anzeige): Baseline-Abweichungen werden in der regulaeren Work-Projektion als `Failed` dargestellt und erscheinen im eigenen Blatt sowie in der Kategorie und Quelle `GPOs`; passende Werte bleiben `Passed`, unentscheidbare Vergleiche `Investigate`. Damit greift der normale Work-Standardfilter fuer Abweichungen, ohne dass die separate Policy-Analyseansicht fuer die taegliche Findingsicht erforderlich ist. Beim Oeffnen werden ausschliesslich Ergebnisse des aktuellen Evaluators beruecksichtigt und pro GPO-Backup der vollstaendigste Baseline-Lauf gewaehlt; ein spaeterer Teillauf kann die vollstaendige Auswertung nicht mehr verdraengen. Der leere Clientzustand enthaelt keinen Verweis mehr auf die separate View. 825 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Ausschliesslich synthetische Regressionen verwendet; keine Kundendaten oder Persistenz veraendert. Phase 13B bleibt in Arbeit.
Verifizierter Zwischenstand 2026-09-06 (direkte Result-DB-Anzeige): Der Local- und PostgreSQL-Read-Pfad projiziert nun jeden aktuellen, deterministischen Baselinevergleich direkt als `policy_deviation` unter dem Source Tool `GPOs`. Abweichung, Uebereinstimmung, Nichtanwendbarkeit und Review bleiben als `fail`, `pass`, `not_applicable` und `warning` getrennt; neutrale Vergleiche behalten Severity `unknown`. Ein bereits durch die Finding Factory angereicherter Vergleich ersetzt seine neutrale Zeile und wird ebenfalls unter `GPOs` gefuehrt, sodass keine Duplikate entstehen. Rohbeobachtungen bleiben ausschliesslich `internal-analysis`. Die PostgreSQL-Projektion materialisiert den grossen Evaluation-Run einmal je aktiver Aggregation statt ihn je Ergebniszeile erneut aus JSONB zu entpacken. Inhaltsfreie Laufzeitpruefung auf dem freigegebenen Dummy: Source Tool `GPOs` vorhanden, 3.638 aktuelle Vergleiche und 3.583 Abweichungen; Listen- und Facettenabfragen jeweils unter 0,5 Sekunden. 826 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Persistenzmutation und keine Einsicht in Kundeninhalte; Phase 13B bleibt fuer Review-Faelle, weitere Preference-CSEs und Exportdetails in Arbeit.
Verifizierter Zwischenstand 2026-09-06 (Evaluation ohne Zusatzklick): Der bestehende automatische Jobpfad fuehrt nach Parsing und Identity-Analyse unmittelbar Snapshotaggregation, Baseline-Evaluation und Finding Factory aus und setzt den Importjob erst danach auf `succeeded`. Der irrefuehrende `Evaluate`-Button wurde aus erfolgreichen beziehungsweise partiellen GPO-Zeilen entfernt; `Retry` bleibt ausschliesslich fuer fehlgeschlagene Importjobs. Der vorhandene projektgebundene Evaluate-Endpunkt bleibt als technische Recovery-Grenze erhalten, ist aber kein regulaerer UI-Arbeitsschritt. Der Progressdialog bleibt waehrend Queue, Import und automatischer Auswertung geoeffnet. Ein neuer Regressionstest sichert die Reihenfolge Analyse -> automatische Evaluation -> Jobabschluss. 827 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Bestandsdaten veraendert; Phase 13B bleibt in Arbeit.
Zwischenstand 2026-09-06 (einzelnes GPO-Delete): Die bisher auf Dead-Letter-Artefakte begrenzte Loeschung gilt nach ausdruecklicher Bestaetigung fuer jeden beendeten GPO-Import. Abhaengige Policy-Snapshot-Aggregationen werden als reproduzierbare Derivate invalidiert; ihre Evaluationen und Findings folgen transitiv. Der Timeslot, andere Quellen und das Environment Profile bleiben erhalten. Laufende oder eingereihte Jobs blockieren die Mutation. Die UI bietet Delete direkt in jeder GPO-Zeile. Keine Migration und keine Mutation des kanonischen Bestands; Phase 13B bleibt in Arbeit.
Zwischenstand 2026-09-06: Die automatische GPO-Pipeline nutzt zuerst beobachtete Betriebssystem-/DC-Daten aus dem EnvironmentSnapshot desselben Timeslots, mit Quellenreferenzen; Functional Levels sind kein Betriebssystemersatz. GPMC-CoreSettings und verschachtelte GPReport-Identitaeten werden nun gelesen. Der speicherbegrenzte SAX-Metadatenparser verarbeitet groessere Backups; eine synthetische Regression mit 2300 GPOs prueft ZIP und Ordnertransport. Security Templates und Advanced Audit erhalten auf Kunden- und Baselinenseite gemeinsame native Zielidentitaeten neben Registry.pol. Nach Import folgen bei eindeutigen Voraussetzungen Aggregation, Evaluation und die weiterhin freigabegesicherte Finding Factory; Work projiziert aktuelle Bewertung und Baselinewert neben den Beobachtungen. Evaluate ersetzt den obligatorischen View-Zwischenschritt.
Verifiziert auf dem ausdruecklich freigegebenen Dummy: erneute Verarbeitung des erhaltenen Originals erfolgreich, 51 aktuelle Beobachtungen und 50 persistierte Bewertungsergebnisse. Diese stehen in Review, nicht auf bestaetigter Uebereinstimmung: Backup-ID/-zeit und Setvollstaendigkeit sind nicht ausreichend belegt; die vorhandene Baselineanalyse ist unvollstaendig. Das referenzierte Original der passenden Baseline fehlt sowohl im konfigurierten LocalAppData-Speicher als auch im vorhandenen Workspace-Speicher. Migration 019 erweitert nur erlaubte Policy-Zielarten; vorher wurde ein Custom-Format-Backup des kanonischen Bestands erstellt und per pg_restore --list auf Lesbarkeit geprueft, kein Restore behauptet. Kundenoriginale und bestehende Runs bleiben unveraendert. Keine anderen Kunden neu verarbeitet. Keine visuelle Browserabnahme; Phase 13B bleibt offen.
Aktueller verifizierter Zusatzstatus vom 2026-09-04: Die nachtraeglich eingefuehrte Regelkatalog-Grenze blockiert den urspruenglich vorgesehenen neutralen Feinvergleich zwischen importierter Microsoft Security Baseline und technisch eindeutig aufgeloesten GPO-Einstellungen nicht mehr. Fehlt fuer alle anwendbaren Kontexte eine Katalogregel, verwendet Evaluator 1.2.0 den importierten Baselinewert direkt mit einem typgetreuen Equality-Comparator und erzeugt reproduzierbar `configured_match` oder `confirmed_configured_deviation`. Eine solche neutrale Abweichung besitzt keine erfundene Severity, keine Risikoregel und bleibt fuer die Finding Factory `ineligible`; erst eine eindeutige freigegebene Katalogregel darf staerker/schwaecher, Abhaengigkeiten, Risiko und Finding-Freigabe ergaenzen. Teilweise ADMX-Metadaten blockieren den exakten Vergleich einer bereits aufgeloesten Einstellung nicht, bleiben aber als Diagnose sichtbar; mehrdeutige oder nur fuer einen Teil der Kontexte passende Regeln, unvollstaendige Kundensnapshots, Baseline-Mehrdeutigkeit, Wertkonflikte und nicht vergleichbare Typen bleiben fail-closed in Review. Die Aktionsprojektion erkennt Evaluationen aelterer Engineversionen nicht mehr als aktuellen Abschluss und bietet fuer denselben Snapshot und dieselbe Baseline eine neue 1.2-Auswertung an. 36 fokussierte Policy-Tests und alle betroffenen GPO-Tests sind erfolgreich. Im Gesamtlauf bestanden 785 Tests; ein fachlich unabhaengiger bestehender statischer Data-Hub-Test erwartet eine einzeilige Quelltextformatierung, waehrend die bereits vorhandene uncommittete UI-Aenderung denselben Ablauf mehrzeilig schreibt; 8 optionale PostgreSQL-Tests wurden uebersprungen. Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt fuer den noch fehlenden Baseline-only/Subject-missing-Vergleich, fachliche Kataloganreicherung, weitere Preference-CSEs und Exportdetails in Arbeit.
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Die bislang fehlende GPO-Anbindung an Work und Result DB ist als additives, timeslotgebundenes Readmodell geschlossen. Jede aktuelle rohe `PolicyObservation` wird als eigener Quelltyp `policy-observation` mit Assessment-, Artefakt-, Backup-, Parser-, Reducer-, Identity-, Wert-, Diagnose- und Locator-Provenienz projiziert. Diese Datensaetze bleiben fachlich `informational`, zeigen IOE und Deviation als nicht anwendbar und werden auch unter dem Result-DB-Standardfilter `Deviation = Yes` als Kontext sichtbar; nur die Finding Factory darf weiterhin eine echte `policy_deviation` erzeugen. Work fuegt `Group Policy · Observations` als eigenes Blatt hinzu und nimmt dieselben Zeilen in `All Records` auf, sodass `Source: Scan` dort `Group Policy` anbietet. Der freigegebene Dummy-Timeslot liefert ueber die laufende PostgreSQL-API 452 Scannerabweichungen plus 51 GPO-Beobachtungen, insgesamt 503 sichtbare Resultate. 13 fokussierte Tests und der Gesamtlauf mit 781 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine SQL-Migration und keine Veraenderung der importierten Originaldaten; Phase 13B bleibt fuer fachliche Identity-/Regelkataloge und bestaetigte Policy-Findings in Arbeit.
Aktueller verifizierter Zusatzstatus vom 2026-09-03: Die bislang implementierte, aber nicht erreichbare Policy-Analyseoberflaeche ist nun aus dem Data-Hub-Clientdetail zugreifbar. Eine erfolgreiche GPO-Quellenzeile verwendet keine irrefuehrende deaktivierte Scanner-Auswahlbox mehr, sondern eine eigene Quellenmarkierung und eine `View`-Aktion. Diese oeffnet die projektgebundene GPO-Analyse mit Reprocessing, Environment Profiles, Aggregation, Evaluation, Finding Factory, aktueller Summary, Historie und Review Queue; zur Stammdatenansicht kann direkt zurueckgewechselt werden. `Source: Scan` bleibt fachlich auf Scanner-Runs begrenzt. Sieben fokussierte Data-Hub-/Policy-UI-Tests, die Gesamtsuite mit 775 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzaenderung; Phase 13B bleibt in Arbeit.
@ -1229,7 +1359,7 @@ Aktueller verifizierter Zusatzstatus vom 2026-08-22: Der globale Microsoft-Secur
Ziele:
- GPMC-/`Backup-GPO`-Backups und Microsoft-Security-Baselinepakete deterministisch, sicher und nachvollziehbar analysieren.
- Richtlinieneinstellungen technisch identifizieren, innerhalb eines konsistenten Kundensnapshots aggregieren, Konflikte erkennen und gegen alle vollständig geprüften technisch passenden Baselinekontexte bewerten.
- Richtlinieneinstellungen technisch identifizieren, innerhalb eines konsistenten Kundensnapshots aggregieren, Konflikte erkennen und jede aktive importierte Baseline separat als nachvollziehbaren Soll-/Ist-Vergleich auswerten; Produkt- und Rollenfilter bleiben eine nachgelagerte Sicht statt einer globalen Importsperre.
- Nur bestätigte konfigurierte Abweichungen als normale Findings erzeugen; tatsächliche Wirksamkeit ohne Scope- oder RSoP-Evidenz niemals behaupten.
Gemeinsame und getrennte Grenzen:
@ -1397,6 +1527,20 @@ Aktualisierung nach der Katalog-Ladegrenze vom 2026-09-03: Fachlich freigegebene
## 9. Bereits getroffene Architekturentscheidungen
Festlegung 2026-09-06 (Phase 8, fehlende Folgequellen): Timeslotvergleiche trennen kuenftig gemessenen Fortschritt, Gesamtexposition und Evidence Coverage/Freshness. Fehlende erwartete Quellen werden nicht als behoben interpretiert. Offene Verpflichtungen werden mit Herkunft und Alter als `carried-forward-awaiting-revalidation` fortgeschrieben und bleiben scorewirksam; Rohbeobachtungen werden nicht als aktuelle Evidenz kopiert. Ein Scope-Ende ist ausschliesslich ueber eine begruendete, auditierte `retired-out-of-scope`-Entscheidung moeglich. Dieser generische Vertrag gilt fuer Scanner, GPOs und spaetere Tools und wird nicht ueber ein primaeres manuelles Auswahlhaekchen gesteuert.
Festlegung 2026-09-06 (Phase 9/10, FindingReader-Reviews): Bearbeitbare Workflowdaten gehoeren zum kanonischen Finding-Review und damit in die ASSESSOR-Datenbank, nicht nur in Local Storage. Die stabile Occurrence-ID verbindet Aggregate und Quellansicht; Datenbankwerte gewinnen beim Laden, Local Storage bleibt Rueckfall und Migrationsquelle. Der exakte UI-Status wird additiv neben dem kanonischen Workflowstatus bewahrt; unbekannte kuenftige Marker bleiben bei Updates erhalten.
Festlegung 2026-09-06 (Phase 13B, einzelnes GPO-Delete): Ein ausdruecklich bestaetigtes, projekt- und artefaktgebundenes Delete ist fuer beendete GPO-Importe zulaessig und entfernt neben der backendkontrollierten Originalkopie alle direkt oder transitiv davon abhaengigen Processing-, Identity-, Snapshot-, Evaluation- und Finding-Derivate. Eine Aggregation mit mehreren Quellen wird vollstaendig invalidiert, sobald eine ihrer Quellen geloescht wird; die uebrigen Quellimporte und das Environment Profile bleiben bestehen und koennen neu aggregiert werden. Queue-/Running-Jobs bleiben gegen Loeschung gesperrt. Die persistente Loesch-Outbox bleibt die Grenze zwischen relationalem Commit und Dateientfernung. Diese Festlegung ersetzt die Entscheidung vom 2026-07-21, Delete ausschliesslich fuer vollstaendig fehlgeschlagene GPO-Artefakte zuzulassen.
Festlegung 2026-09-06 (Phase 13B, Baseline-Reimport): Eine ausdruecklich bestaetigte Baseline-Loeschung darf referenzierende, reproduzierbare Evaluation-/Finding-Derivate transitiv entfernen. Unveraenderliche Kundenquellen, GPO-Processing-Runs, Beobachtungen, Environment Profiles und Snapshotaggregationen werden dadurch nicht geloescht. Diese Reimportfunktion ersetzt die Entscheidung vom 2026-08-22, referenzierte Baselines grundsaetzlich zu sperren.
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 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.
| Datum | Entscheidung | Begründung |
| 2026-09-03 | Die verbindliche Client-Info-Startansicht expandiert nur `Assessment Status`; alle neun anderen Informationskarten starten minimiert. | Der Gesamtstatus bleibt ohne Interaktion sichtbar, waehrend die darunterliegende feste 3/2/2-Matrix und die rechte Rail als kompakte, stabile Navigation beginnen. Inhalte werden nicht entfernt und bleiben ueber die Standarddreiecke einzeln erreichbar. |
| 2026-09-03 | Minimierte Client-Info-Karten bleiben ueber die volle Breite ihrer festen Spalte gespannt. `Directory Overview` zeigt keine werkzeugspezifische PingCastle-Engine-Version mehr. | Collapse soll ausschliesslich die Hoehe reduzieren und darf die stabile Spaltengeometrie nicht veraendern. Die Directory-Karte beschreibt den Verzeichniszustand; eine Scanner-Engine-Version gehoert nicht zu diesem fachlichen Ueberblick und bleibt nur in der unveraenderten Provenienz erhalten. |
@ -1672,6 +1816,10 @@ Abnahme 2026-08-23: 677 Standardtests bestanden, 8 optionale PostgreSQL-Integrat
## 10. Offene Entscheidungen
Festgelegte Versionsentscheidung vom 2026-09-06: Die sichtbare ASSESSOR-Version ist die eigenstaendige Produktversion und wird nicht aus Parser-, Mapping-, Processing- oder GPO-Evaluatorversionen abgeleitet. `2.5.1` bezeichnet den aktuellen Produktstand; technische Komponentenstaende bleiben im About-Popup und in der Ergebnisprovenienz getrennt sichtbar. Produktversionen folgen Semantic Versioning: Patch fuer kompatible Korrekturen, Minor fuer neue kompatible Funktionen, Major fuer bewusst inkompatible Produkt- oder Datenvertragswechsel.
Festgelegte Fachentscheidung vom 2026-09-06: Ein GPO-Benchmark darf nur dann `Passed` oder `Failed` liefern, wenn die passend ausgewaehlte Microsoft-Security-Baseline einen vorhandenen und technisch vergleichbaren Sollwert vorgibt. Kundenwerte ohne Microsoft-Sollwert werden nicht als Abweichung gewertet; leere Sollwerte entfallen, nicht unterstuetzte Sollwerte bleiben Review/Warning. Die spaetere optionale Darstellung reiner Kundenkonfigurationen als Info bleibt offen.
- Aufbewahrung oder bewusster Verzicht auf vollständige Originaldateien normaler Scannerimporte; für GPO- und Baseline-Reprocessing ist die unveränderte Aufbewahrung beschlossen
- Verschlüsselung der Datenbank beziehungsweise Anforderungen an Datenträgerverschlüsselung
- Verschlüsselung, Speicherort, Zugriffsschutz und Löschfristen des GPO-/Baseline-Artefaktspeichers
@ -1689,6 +1837,20 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 11. Risiken
| Die Produktversion bleibt trotz funktionaler Aenderungen unveraendert oder wird mit einer technischen Komponentenfassung verwechselt; Frontend und Backend koennen zudem unterschiedliche Builds ausliefern. | Produktversion zentral aus dem Paket beziehen, im Markenblock zeigen, Buildzeit und Backend-Release im About-Popup ausweisen und die Backend-Produktversion ueber Health melden. Releaseprozess und Tests muessen die bewusste SemVer-Aenderung pruefen. |
| Leere oder technisch nicht normalisierbare Microsoft-Baselinewerte koennten als Abweichung erscheinen und die Work-Liste mit nicht belastbaren Findings aufblasen. | Baselinezeilen ohne Sollwert vor der Evaluation ausschliessen; Pass/Fail nur bei vorhandenem vergleichbarem Baselinewert zulassen; nicht unterstuetzte Werte als Review/Warning fuehren und synthetisch sowie per inhaltsfreier Zaehlerpruefung absichern. |
Festgelegtes Vergleichsrisiko vom 2026-09-06: Wenn eine im Folgetimeslot fehlende Quelle still als nicht mehr vorhanden gilt, erscheinen offene Risiken faelschlich behoben und Score sowie Fortschritt lassen sich durch weniger Evidenz verbessern. Gegenmassnahme: erwartete Quellen explizit zustandsbehaftet fuehren, nur aktuell beidseitig beobachtete Quellen fuer gemessenen Fortschritt verwenden, offene letzte Findings separat und scorewirksam fortschreiben sowie Coverage/Freshness sichtbar ausweisen. Rohbeobachtungen duerfen dabei nicht als aktuelle Messung dupliziert werden.
Festgelegtes Review-Persistenzrisiko vom 2026-09-06: Dieselbe Finding-Occurrence kann in mehreren Work-Blaettern unterschiedliche Anzeige-IDs besitzen; eine rein an diese IDs gebundene Browserablage kann Owner, Termine und Massnahmen auseinanderlaufen oder bei Browserwechsel verloren gehen. Gegenmassnahme: kanonische Occurrence-/Definition-IDs in die Projektion tragen, alle Projektionen synchronisieren, ueber die Same-Origin-API optimistisch versioniert speichern und die lokale Ablage nur als Rueckfall/Migrationsquelle behandeln.
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.
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.
| Risiko | Gegenmaßnahme |
| Eine spaetere Kartenrefaktorierung kann wieder alle Client-Info-Karten standardmaessig oeffnen und damit die vereinbarte kompakte Startansicht verlieren. | Den Startzustand an jeder konkreten Karteninstanz explizit setzen und statisch pruefen: neun `initiallyExpanded={false}` sowie genau ein nicht minimierter `Assessment Status`; die gemeinsame Komponente bleibt fuer gezielte Wiederverwendung konfigurierbar. |
| Eine minimierte Client-Info-Karte kann durch intrinsische Flexbreite schmaler als ihre Spalte werden; werkzeugspezifische Provenienz kann ausserdem erneut in einen fachlichen Verzeichnisueberblick geraten. | Collapsed-Karten explizit auf `width: 100%` setzen und diese Geometrie regressionspruefen. `directoryFacts` auf fachliche Directory-Felder begrenzen; Scanner-Engine-Versionen bleiben in der Quellprovenienz statt in `Directory Overview`. |
@ -1782,6 +1944,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| Eine lokale ScanRun-Abwahl koennte beim Wechsel des Timeslots unbemerkt weiterwirken, neue Importe ausschliessen oder bei Mehrfachauswahl die bestehende Vergleichsschnittmenge umgehen. | Ausschliesslich ausgeschlossene Run-IDs lokal speichern, den Zustand beim Mandanten-/Timeslot-Neueinstieg und nach Loeschungen bereinigen und neue Runs dadurch standardmaessig einschliessen. Fuer `Open` zuerst je Timeslot die lokale Auswahl projizieren und erst danach unveraendert die sichere gemeinsame Werkzeugmenge berechnen; ohne verwertbaren Run bleibt `Open` gesperrt. |
| Work und Graph koennten bei getrennten lokalen Filterzustaenden unterschiedliche Findingmengen zeigen oder werkzeugspezifische Facetten unvollstaendig abbilden. | Beide Ansichten ausschliesslich aus dem gemeinsamen AppProvider-Zustand und `useFilteredRecords()` speisen; im Graph dieselben Search-/Sheet-/Facet-Controls wiederverwenden und lokale Graphfilter durch Strukturtests ausschliessen. Nur rein praesentative Graphzustaende lokal halten. |
| Ein dichter Netzwerkgraph kann bei vielen Findings durch zu viele Labels, Kanten oder unkontrollierte Layoutberechnungen unlesbar beziehungsweise traege werden; die gepunktete Graphflaeche kann im Lightmode zu dominant wirken. | Deterministisches lineares Clustering statt quadratischer Laufzeit verwenden, Clusterabstaende und Karten kompakt begrenzen, Labels standardmaessig auf Auswahl/Hover reduzieren, Fit/Zoom bereitstellen und Graphdaten rein aus der bestehenden gefilterten Projektion ableiten. Punktfarbe und Knotenrollen ausschliesslich ueber semantische Dark-/Light-Tokens steuern und das Muster strikt auf die Graphansicht begrenzen. |
| Ein einzelnes GPO-Delete koennte waehrend der Verarbeitung mit dem Worker konkurrieren oder nach einer bereits erfolgten Evaluation verwaiste Snapshot-/Finding-Derivate hinterlassen. | Queue-/Running-Jobs an der Repository-Grenze blockieren. Vor dem Artefaktdelete jede Snapshotaggregation entfernen, die eine Identity-Analyse dieses Artefakts verwendet; relationale Cascades beziehungsweise die identische lokale Projektion entfernen Evaluationen und Findings. Timeslot, andere Imports und Environment Profile erhalten und die Dateikopie ueber die persistente Outbox entfernen. |
| Ein in der Timeslot-Zeile platzierter Delete-Button koennte versehentlich den gesamten Mandanten oder nur sichtbare Scanzeilen loeschen und GPO-/Evidenzreste hinterlassen. | Mandanten- und Timeslot-Loeschung strikt trennen. Timeslot-Delete verlangt Projekt- und Assessment-ID plus Cascade-Bestaetigung an der Repository-Grenze und entfernt den Assessment-Teilbaum; der Dialog nennt den betroffenen Stichtag und bestaetigt ausdruecklich, dass Mandant und andere Timeslots erhalten bleiben. |
| Mehrere Timeslots besitzen unterschiedliche Scanner- oder GPO-Abdeckung; ein ungefiltertes Oeffnen wuerde fehlende Quellen als fachliche Veraenderung interpretieren und Aepfel mit Birnen vergleichen. | Vor der Mehrfachprojektion pro Timeslot die sicher erkannte Quellenmenge bilden und nur deren Schnittmenge weiterreichen. Generische, unbekannte oder gemischte Werkzeugidentitaeten nicht automatisch gleichsetzen; GPO separat als Assessment-Quelle behandeln und fehlende beziehungsweise ausgeschlossene Quellen sichtbar nennen. |
| Ein Delete-Button koennte eine bereits fuer Policy-Evaluationen verwendete Microsoft-Baseline oder nur deren sichtbare Tabellenzeile entfernen und dadurch Evidenz beziehungsweise Artefakte inkonsistent hinterlassen. | Expliziten Bestaetigungsdialog und bestaetigungspflichtigen DELETE-Vertrag verwenden; Referenzen in Local- und PostgreSQL-Persistenz vor der Mutation blockieren, abhaengige Klassifikationen und Analysen atomar mit dem Katalogeintrag entfernen und das danach unreferenzierte private Speicherobjekt best-effort bereinigen. |
@ -1902,6 +2065,33 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-09-06 | Phase 7: GPO-Work-Kacheln gegen ueberlappende lange Registry- und Policy-Pfade abgesichert. `group-policy`-Kacheln tragen eine explizite Toolkennung und erlauben im Titel sichere Umbrueche innerhalb ungeteilter Pfadsegmente; Titel bleiben in ihrer mittleren Gridspalte und ueberdecken weder Bookmark noch Nachbarkachel. Andere Findingtitel behalten die bestehende wortbasierte Typografie. 42 fokussierte synthetische Tests, Designpruefung und Client-Produktionsbuild erfolgreich; keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 7/13B: GPO-only-Timeslotfreigabe auf alle Clientansichten vereinheitlicht. Die zentrale Scope-Regel akzeptiert mindestens eine Scanner-Run-ID oder Assessment-ID; Client Info, Work, Result DB und Pathfinder behandeln damit reine GPO-Timeslots als ausgewaehlt, waehrend Datahub und DB Structure scannertypunabhaengig bleiben. Result-DB-Laden und -Export verwenden denselben projekt- und assessmentgebundenen Scope. Kein automatisches Beimischen anderer Timeslots oder Scannerlaeufe. 25 fokussierte synthetische Tests, Designpruefung und Client-Produktionsbuild erfolgreich; keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 7: Englisch als verbindliche Sprache fuer saemtliche nutzerseitig sichtbaren ASSESSOR-Texte festgelegt, einschliesslich Navigation, Labels, Aktionen, Status-, Lade-, Leer-, Fehler-, Bestaetigungs- und Dialogtexte sowie kuenftiger Funktionen. Deutsche Texte bleiben auf interne Dokumentation, Kommentare oder ausdruecklich angeforderte Exportinhalte begrenzt. Den verbliebenen deutschen Ladehinweis der Data-Hub-Historie auf `Loading clients …` umgestellt. Keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 7: Gemeinsamen markenfreien Panel-Ladezustand eingefuehrt. Der konkrete Ladetext wird mit der aus den Processing-Pop-ups wiederverwendeten quadratischen Ladeanimation direkt daneben im verfuegbaren Inhaltsbereich zentriert. Client Info, DB Structure, Result DB, Data-Hub-Historie, Timeslot-Liste und Policy Analysis verwenden denselben Vertrag, eingebettete Bereiche in einer kompakten Variante. Fehler-, Leer- und Operationszustaende bleiben getrennt. 835 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 7/13B: Erfolgreich fertig verarbeitete GPO-Backups werden in der Timeslot-Quellenzeile als `Ready` statt `Imported` bezeichnet. Die Aenderung betrifft ausschliesslich das Label des Endzustands `succeeded`; Queue-, Verarbeitungs-, Teil-, Fehler- und Recovery-Zustaende bleiben getrennt. Zehn fokussierte synthetische Tests und Client-Produktionsbuild erfolgreich; keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 7: ASSESSOR-Branding auf eine gemeinsame transparente rote Marke umgestellt. Weisser Hintergrund, weisses A-Innenfeld und Haken sind echte transparente Aussparungen; Sidebar, Empty Workspace, Favicon und About verwenden dasselbe Asset. Der Brand-Trigger bleibt klickbar und zugaenglich beschriftet, besitzt aber keinen sichtbaren Hover-Buttonzustand. About zeigt das Logo mit 160 px prominent. Transparenz inhaltsfrei auf Alpha und fehlende deckende Weisspixel geprueft; fuenf fokussierte Tests sowie Client-/Serverbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 13B: GPO-Importduplikatfehler, veraltete Kontextpflicht und Upload-Snapshotzerlegung korrigiert. Observation-IDs aller Policy-Reducer binden neben Backupinstanz und Datei-Hash nun auch den exakten Quellartefaktpfad ein; byte-identische Dateien an unterschiedlichen GPO-Pfaden bleiben dadurch getrennte gueltige Beobachtungen. Reine GPO-Backups werden ohne zusaetzlichen OS-Scan automatisch gegen alle `eligible` importierten Microsoft-Baselines als getrennte Benchmarks ausgewertet; vorhandene DC-Kontexte bleiben Evidenz. Ein gemeinsam hochgeladener `selected-gpos`-Satz bleibt bei fehlenden oder gemischten Domainmetadaten ein einzelner partieller Snapshot. Parser-/Analyzervertrag auf 2.2.2, Snapshotaggregation auf 1.1.0 und Produktversion als kompatibler Patch auf 2.5.1 angehoben. Regressionen fuer identische Dateien, kontextfreien Auto-Vergleich, Baseline-Auswahl, gemischte Uploadmetadaten und 2.300-GPO-Lastfall gruen. Inhaltsfreie technische Kontrolle am betroffenen Import: HTTP 200, kein fehlgeschlagener Job, 184 erkannte GPOs und 3.638 Work-Ergebnisse (162 Uebereinstimmungen, 3.358 Abweichungen). Gesamtlauf 835 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Keine SQL-Migration. |
| 2026-09-06 | Phase 7: Echte ASSESSOR-Produktversion 2.5.0 eingefuehrt und von Processing-/GPO-Komponentenversionen getrennt. Der klickbare Markenblock oeffnet ein kompaktes About-Popup mit Buildzeit, Backend-Release, Importvertrag, GPO-Evaluation, Vergleich, Baselineanalyse, Snapshotaggregation, Policy-Identitaet und Credits fuer Michael Lukas und Binh Karl. Health meldet die Backend-Produktversion. 831 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung sowie Client-/Serverbuild erfolgreich. Keine Daten- oder Schemamigration. |
| 2026-09-06 | Phase 13B: GPO-Benchmark auf die fachlich bestaetigte Microsoft-Sollwertsemantik begrenzt. Evaluator 1.3.3 waehlt nur zur beobachteten Umgebung passende Baselines, ignoriert leere Baselinezeilen und verhindert Pass/Fail bei nicht vergleichbaren Sollwerten. Security-Template-Registrywerte sind typbewusst vergleichbar. Nur Dummy neu ausgewertet: 363 Failed, 13 Passed, 8 Review/Warning und 0 Pass/Fail ohne Baselinewert. 831 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Keine anderen Mandanten oder Originaldaten veraendert. |
| 2026-09-06 | Phase 13B: GPO-Baseline-Abweichungen als regulaere `Failed`-Zeilen in Work angebunden. Das eigene Blatt sowie Kategorie und Quelle heissen einheitlich `GPOs`; der normale Abweichungsfilter zeigt die Ergebnisse ohne Policy-Analyse-Zwischenschritt. Die Workspace-Ladung verwendet den aktuellen Evaluator und pro GPO-Backup den vollstaendigsten Baseline-Lauf, damit spaetere Teillaeufe keine vollstaendige Auswertung verdraengen. Den veralteten View-Hinweis im leeren Clientzustand entfernt. 825 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Serverbuild erfolgreich. Keine Daten- oder Persistenzmutation. |
| 2026-09-06 | Phase 8/9/10: Das vereinbarte Quellen-Fortschreibungsmodell fuer unvollstaendige Folgetimeslots konkret geplant: drei Evidenzzustaende, getrennte Kennzahlen fuer gemessenen Fortschritt, Gesamtexposition und Evidence Coverage/Freshness, keine automatische Behebung oder Scoreverbesserung bei fehlender Quelle sowie auditierter Scope-Ausstieg. Zugleich die vollstaendige FindingReader-Bearbeitung an die bestehende Datenbank-Reviewgrenze angeschlossen. Stabile Occurrence-IDs verbinden Aggregate und Werkzeugblaetter; Bookmark, Status, Prioritaet, Owner/Team, Due Date, fuenf Freitextfelder und alle fuenf Marker werden optimistisch versioniert gespeichert. Vorhandene Browserreviews werden ohne Datenbankgegenstueck uebernommen, Datenbankreviews haben Vorrang. Additive JSONB-Erweiterung ohne SQL-Migration; 43 fokussierte synthetische Tests, 825 Standardtests, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich; 8 optionale PostgreSQL-Tests uebersprungen. Keine realen Kundendaten gelesen oder veraendert. |
| 2026-09-06 | Phase 13B auf den vom Nutzer bestaetigten Policy-Analyzer-Vergleich umgestellt: Evaluator 1.3.1 fuehrt je aktiver Baseline einen baseline-getriebenen Left Join gegen den importierten GPO-Istbestand aus. Gleich ist Passed, verschieden oder fehlend ist Abweichung, ohne Baselineempfehlung bleibt unbewertet; mehrere Baselines werden getrennt statt als Kontextmehrdeutigkeit behandelt. Konflikte und technisch unvergleichbare Werte bleiben Review, neutrale Vergleiche erzeugen keine Severity oder Findings. Migration 020 bindet jedes Ergebnis an genau eine Baselineanalyse und erlaubt fuer fehlende Ist-Werte eine NULL-Aggregationsreferenz. Work projiziert jeden Baselinevergleich als eigene Zeile mit kurzem Baseline-Namen; deduplizierte GPO-Reimporte koennen die aktuelle Baseline-Menge erneut auswerten. 821 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only, Designpruefung und Client-/Serverbuild erfolgreich. Vor der Dummy-Neuauswertung wurde ein Custom-Format-Backup erstellt. Ausschliesslich Dummy wurde neu ausgewertet: 3.638 Ergebnisse, 55 Passed, 3.556 Abweichungen, 27 Review; davon 3.483 explizit fehlende Ist-Werte. Keine anderen Mandanten ausgewertet. Phase 13B bleibt in Arbeit. |
| 2026-09-06 | Phase 7/13B: In jeder Timeslot-GPO-Zeile steht nun eine eigene Delete-Aktion neben Evaluate beziehungsweise Retry. Der Warnungsdialog benennt den exakten GPO-Eintrag und garantiert den Erhalt des Timeslots und aller anderen Quellen. Der bestehende bestaetigungspflichtige DELETE-Endpunkt wurde von Dead Letter auf alle beendeten GPO-Importe erweitert; Queue/Running bleibt gesperrt. Local- und PostgreSQL-Persistenz entfernen atomar das Artefakt sowie direkt und transitiv abhaengige Processing-, Identity-, Snapshot-, Evaluation- und Finding-Derivate, waehrend die persistente Artefakt-Loesch-Outbox erhalten bleibt. Synthetische Regressionen pruefen erfolgreichen Import samt Snapshot und Reprocessing-Kette, Dead Letter, parallele Queue-Sperre, HTTP-Bestaetigung und Clientroute. 829 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Der GPO-Pfad benoetigt keine neue SQL-Migration und hat keine kanonischen Fachdaten mutiert. Beim anschliessenden Neustart wurde die bereits anstehende Migration 020 nach eindeutiger Benennung ihrer beiden CHECK-Constraints auf dem kanonischen 2.0-Bestand erfolgreich registriert; App und PostgreSQL-Backend liefern HTTP 200. Keine Realdaten gelesen und keine neue Datenbank erzeugt; Phase 7 bleibt abgeschlossen, Phase 13B in Arbeit. |
| 2026-09-06 | Phase 7: Timeslot-Auswahl und Expansion wieder strikt getrennt. Ausschliesslich ein Klick auf die Checkbox markiert beziehungsweise demarkiert den Timeslot; Datum, freie Balkenflaeche, Zaehler und Dreieck gehoeren zur auf- und zuklappbaren Summary. Die bisherige Label-Klickflaeche um Datum und Checkbox wurde durch einen neutralen Container ersetzt, nur die Checkbox stoppt die Summary-Interaktion. 827 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-09-06 | Phase 7/13B: die vorhandene Fortschrittsanzeige als gemeinsame, nicht vorzeitig schliessbare Progress-Dialogkomponente fuer Scanimporte, Security-Baseline-Uploads, GPO-Ordner-/ZIP-Uploads sowie Profil-, Snapshot-, Baseline- und Finding-Berechnungen vereinheitlicht. GPO-Upload und Retry bleiben ueber die persistierte Job-ID bis zum Endzustand sichtbar statt bereits nach dem Queuing zu verschwinden; Texte sind bewusst auf einen kurzen aktuellen Arbeitsschritt begrenzt. 827 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, Schema-, Persistenz- oder Datenmutation; Phase 7 bleibt abgeschlossen, Phase 13B in Arbeit. |
| 2026-09-06 | Phase 13B: Baseline-Delete fuer Vollreimporte erweitert. Nach ausdruecklicher Bestaetigung werden referenzierende, reproduzierbare Policy-Evaluation-/Finding-Ketten entfernt; Kunden- und GPO-Quelldaten bleiben erhalten. Dialogtext verkuerzt. Fokussierte 20 Tests und Client-/Serverbuild erfolgreich. Vor dem Eingriff kanonisches PostgreSQL-Backup erstellt und lesbar geprueft; den letzten blockierten Baselineeintrag geloescht. Verifiziert: Katalog 0, Backend PostgreSQL gesund, App HTTP 200 und 51 GPO-Beobachtungen unveraendert vorhanden. |
| 2026-09-06 | Phase 12/13B um die verbindliche Datenschutzgrenze der Result DB erweitert. PingCastle- und kuenftige Scanner-Zusatzinformationen werden nur ueber `result-db-supplemental-disclosure` 1.0.0 freigegeben: exakte Tool-/Property-Allowlist, enge Wertevalidatoren und kontrolliert neu aufgebaute Titel/Subject-Klassen; Identitaet, Originalwert, Attribute, Asset, Abschnitt, Locator und Quellzeitpunkt bleiben intern. Rohe GPO-Policy-Beobachtungen sind nur noch im expliziten `internal-analysis`-Modus fuer Work/Data-Hub-Auswertung verfuegbar, waehrend bestaetigte Policy-Findings in der Result DB bleiben. JSONL-Schema 1.1.0 erzwingt die Klassifikation und entfernt gesperrte Felder nochmals vor Serialisierung. 31 fokussierte Tests, alle 826 Standardtests, 8 erwartungsgemaess uebersprungene PostgreSQL-Tests, Local-only-/Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Ausschliesslich synthetische Daten verwendet; keine Original-, Persistenz- oder Schemaaenderung. |
| 2026-09-06 | Phase 13B/13C: automatische timeslotgebundene PingCastle-DC-Kontexte, GPMC-Core-/GPReport-Metadaten, begrenzter SAX-Parser, gemeinsame native Security-/Audit-Ziele, Import-Folgepipeline und Work-Bewertungsspalten implementiert. Migration 019 nach lesbar geprueftem Backup auf dem kanonischen Bestand angewandt; ausschliesslich Dummy neu verarbeitet: 51 Beobachtungen, 50 Review-Ergebnisse. 821 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only, Designpruefung und Client-/Serverbuild erfolgreich. Fehlendes Baselineoriginal und unzureichende Backupvollstaendigkeit bleiben dokumentierte Blocker; kein abgeschlossener Baseline-Abgleich und keine visuelle Browserabnahme behauptet. Temporaeren Dummy-QA-Test entfernt, Backup behalten. |
| 2026-09-06 | Phase-7/12-Timeslotnavigation auf einen einzigen vertikalen Scrollcontainer vereinheitlicht: Die Quellenliste innerhalb eines ausgeklappten Timeslots besitzt keine eigene Maximalhoehe und keine eigene Scrollbar mehr. Ein Timeslot waechst mit allen Quellen und Aktionen; gescrollt wird ausschliesslich die gesamte aeussere Timeslot-Liste mit ihrem unteren Abstand. Die fokussierte Strukturregression trennt den erlaubten aeusseren `overflow-y`-Vertrag von der verbotenen inneren Scrollbegrenzung. Keine Daten-, API-, Persistenz- oder Fachlogikaenderung; Phase 7 und 12 bleiben abgeschlossen. |
| 2026-09-06 | Phase-7/12-Timeslotnavigation nach Nutzerhinweis gegen eine optisch abgeschnittene letzte ausgeklappte Karte korrigiert: Der eigene scrollbare Timeslotlistenbereich behaelt seitlich und oben seine kompakte Geometrie, erhaelt unten jedoch den gemeinsamen 20-px-Abstand. Dadurch koennen Abschlusskontur und letzte Aktion vollstaendig ueber die Viewport-Unterkante gescrollt werden. Drei fokussierte Strukturtests, Designpruefung sowie Client-/Server-Produktionsbuild sind erfolgreich; keine Daten-, API-, Persistenz- oder Fachlogikaenderung. Phase 7 und 12 bleiben abgeschlossen. |
| 2026-09-06 | Phase-11-Lokalbetrieb auf ausdruecklichen Nutzerwunsch konsolidiert: Der bekannte Datenstand `assessor-2_0` ist als einziger kanonischer lokaler ASSESSOR-PostgreSQL-Bestand festgelegt. `docker-compose.postgres.yml` verwendet dauerhaft den Compose-Projektnamen `assessor-2_0` und bindet `assessor-2_0_assessor-postgres-data` als vorhandenes externes Volume; `AGENTS.md` verbietet alternative ASSESSOR-Datenbanken und Fallbackports. Zwei fokussierte Konfigurationsregressionen, Compose-Validierung, Client-/Server-Produktionsbuild sowie HTTP-Health von Client und Backend sind erfolgreich. Der Gesamttest besitzt weiterhin genau den bereits dokumentierten unabhaengigen statischen Data-Hub-Open-Befund zur alten Quelltextformatierung. Keine Schema- oder Fachdatenaenderung; die separat bestaetigte Bereinigung der ASSESSOR-Container und -Volumes fuer 2.5, 2.4.1, 2.4 und den Phase-13-Testbestand betraf weder den kanonischen 2.0-Bestand noch fremde Docker-Anwendungen. Phase 11 bleibt abgeschlossen. |
| 2026-09-04 | Phase-13B-GPO-Ordnerimport gegen einzelne nicht oeffenbare Windows-Verzeichniseintraege gehaertet: Direkter Picker und Fallback behandeln `getFile`-, Verzeichnisiterator- und Byte-Lesefehler pro Eintrag, behalten alle lesbaren Dateien und melden die Anzahl ausgelassener Eintraege ausdruecklich in der Erfolgsnachricht statt den gesamten Import mit der rohen Browser-NotFound-DOMException abzubrechen. Sind keine Dateien lesbar, bleibt der Import geschlossen. Metadaten- und Ressourcengrenzen gelten vor der Byteerfassung weiter. Eine neue synthetische Regression kombiniert lesbaren Inhalt, nicht oeffenbaren Handle und nicht lesbaren Dateikoerper; insgesamt 16 fokussierte UI-, Transport- und HTTP-Tests, Local-only-Sicherheitspruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Realdaten gelesen oder importiert und keine API-, Persistenz- oder Schemaaenderung; die UI macht eine moegliche Unvollstaendigkeit sichtbar, Phase 13B bleibt in Arbeit. |
| 2026-09-04 | Phase-13B-GPO-Ordnerauswahl nach wiederholtem Windows-Dateireferenzfehler auf einen neuen Primaerpfad umgestellt: `Import GPO Folder` verwendet bei Verfuegbarkeit den explizit durch den Nutzer freigegebenen File-System-Access-Verzeichnispicker, traversiert ausschliesslich den gewaehlen Baum read-only und konstruiert stabile relative Pfade; der bisherige `webkitdirectory`-Selektor bleibt nur fuer Browser ohne diese API als Fallback bestehen. Beide Pfade laufen weiterhin durch denselben Metadaten-Preflight, die unmittelbare Byteerfassung, deterministische ZIP-Kompression und die unveraenderte projekt-/timeslotgebundene Submission. Eine neue synthetische Rekursionsregression sichert den direkten Verzeichnisvertrag; insgesamt 15 fokussierte UI-, Transport- und HTTP-Tests, Local-only-Sicherheitspruefung, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Realdaten gelesen oder importiert, keine allgemeinen Dateisystemrechte und keine API-, Persistenz- oder Schemaaenderung; Phase 13B bleibt in Arbeit. |
| 2026-09-04 | Phase-13B-GPO-Verzeichnisupload nach erneuter Realfehler-Rueckmeldung auf sofortige Dateierfassung umgestellt: Alle Browser-Leseoperationen werden jetzt noch synchron im Auswahlereignis gestartet, bevor Windows einzelne spaeter sequenziell geoeffnete Verzeichnisreferenzen verlieren kann; die bereits erfassten Bytes werden anschliessend deterministisch komprimiert. Ein gemeinsamer plattformneutraler Metadaten-Preflight prueft Anzahl, Einzel- und Gesamtdatenmenge vor dem Start irgendeiner Leseoperation, sodass die sofortige Erfassung die bestehenden Ressourcengrenzen nicht umgeht. Drei neue synthetische Regressionen sichern gleichzeitigen Lesestart, begrenzte NotFound-Abbildung und Ablehnung vor dem Lesen; insgesamt 14 fokussierte UI-, Transport- und HTTP-Tests, Local-only-Sicherheitspruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Realdaten gelesen oder importiert und keine API-, Persistenz- oder Schemaaenderung; Phase 13B bleibt in Arbeit. |
| 2026-09-04 | Phase-13B-GPO-Dateiauswahl gegen waehrend der asynchronen Kompression verlorene Browser-Dateireferenzen gehaertet: Ordner- und ZIP-Input werden nicht mehr unmittelbar nach dem Change-Ereignis geleert, sondern erst nachdem Einlesen, Verpacken und Submission beendet sind. Der Ordnerpfad uebergibt zuvor eine stabile File-Liste; ein tatsaechlich nicht lokal verfuegbarer Eintrag liefert eine konkrete, inhaltsfreie Handlungsanweisung statt der rohen Browser-DOMException. Elf fokussierte UI-, Transport- und HTTP-Tests, Local-only-Sicherheitspruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Realdaten gelesen oder importiert und keine API-, Persistenz- oder Schemaaenderung; Phase 13B bleibt in Arbeit. |
| 2026-09-04 | Phase-13B-GPO-Ordnertransport an die vorhandenen sicheren Archivgrenzen angeglichen: Der Browser erzeugt fuer einen ausgewaehlten Backupordner nun ein deterministisches ZIP mit verlustfreier Deflate-Kompression pro Datei und behaelt unveraendert CRC-, Pfadnormalisierungs-, Kollisions-, Eintrags-, Einzeldatei- und Transportpruefungen. Dadurch darf der gepruefte Ordnerinhalt bis zur bereits serverseitig geltenden Grenze von 512 MiB reichen, waehrend die tatsaechlich uebertragene ZIP-Datei weiterhin auf 100 MiB begrenzt bleibt; nicht komprimierbare Inhalte koennen diese Transportgrenze weiterhin nicht umgehen. Eine synthetische Byte-fuer-Byte-Regression belegt, dass Quellinhalt oberhalb einer kleineren Transportgrenze komprimiert, serverseitig verifiziert und identisch wiederhergestellt wird. Neun fokussierte GPO-Transport-/HTTP-Tests, Local-only-Sicherheitspruefung, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Im Gesamtlauf bestanden 791 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet weiterhin die alte einzeilige Quelltextformatierung der funktional vorhandenen Dashboard-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realdaten gelesen oder importiert und keine API-, Persistenz- oder Schemaaenderung; Phase 13B bleibt in Arbeit. |
| 2026-09-04 | Phase-7/13B-Work-Quellenfilter nach der additiven GPO-Projektion korrigiert: Die projektweite Facette `Source: Scan` bleibt innerhalb der auf zehn sichtbare Filter begrenzten Leiste verbindlich als letzter sekundaerer Filter erhalten und kann dadurch nicht mehr von den zusaetzlichen GPO-Feldern verdraengt werden. Bestehende Scannerblaetter und Werkzeugwerte bleiben unveraendert; `Group Policy` wird nur als weitere Quelle und als eigenes Beobachtungsblatt ergaenzt. Zwei neue synthetische Regressionen sichern vier Scannerwerkzeuge plus GPO sowie die Filtergrenze ab. 15 fokussierte Tests, Local-only-Sicherheitspruefung, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Im Gesamtlauf bestanden 790 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet weiterhin die alte einzeilige Quelltextformatierung der funktional vorhandenen Dashboard-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realinhalte gelesen und keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen und Phase 13B in Arbeit. |
| 2026-09-03 | Phase-7-Client-Info-Standardansicht nach Nutzerreferenz festgelegt: `Assessment Status` startet als einzige geoeffnete Informationskarte; Directory Coverage, Certificates & PKI, Infrastructure Details, Identity & Privilege, GPO & Password Hygiene, Control Paths & Trusts, Infrastructure & Resilience, Severity Distribution und Directory Overview starten minimiert und vollbreit. Alle Karten bleiben ueber die gemeinsamen Dreiecke individuell schaltbar. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests und Client-/Server-Produktionsbuild erfolgreich. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-09-03 | Phase-7-Client-Info-Detailkorrektur umgesetzt: minimierte Informationskarten behalten mit voller Spaltenbreite dieselbe Breite wie im maximierten Zustand; Collapse reduziert nur die Hoehe. Der PingCastle-Engine-Hinweis wurde aus `Directory Overview` entfernt, ohne die normalisierte Quellprovenienz zu veraendern. 13 fokussierte Dashboard-/Interaktionstests und Client-/Server-Produktionsbuild erfolgreich. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-09-03 | Phase-7-Client-Info auf stabile Spalten und eine geschlossene Kopfzone korrigiert. Das laufzeitgemessene dichte Masonry wurde durch drei feste, semantisch zugeordnete Kartenstapel mit 3/2/2 Karten ersetzt; minimierte und maximierte Karten bleiben dadurch immer in ihrer Spalte und nur nachfolgende Karten derselben Spalte ruecken auf. Die sechs KPI-Kacheln fuellen auf Desktop gemeinsam die Hoehe von `Assessment Status`, sind groesser typografiert und folgen nun Compliance Score, Deviation, High Severity, Passed, Typed Observations, Directory Sections. Compliance ist als H1 hervorgehoben; ausschliesslich Deviation und High Severity sind klickbar. 15 fokussierte Dashboard-/Interaktions-/Dreieckstests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich. Im Gesamtlauf bestanden 780 Tests; eine unabhaengige statische Data-Hub-Open-Pruefung erwartet noch die alte einzeilige Quelltextdarstellung der weiterhin korrekt vorhandenen `dashboard`-Navigation, 8 optionale PostgreSQL-Tests wurden uebersprungen. Keine Realinhalte gelesen und keine Daten-, Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |

View File

@ -104,7 +104,7 @@ Jede Ableitung trägt `ioe_rule_id`, `ioe_rule_version`, `ioe_derivation_method`
| CIS-CAT | XCCDF informational | compliance_check | xccdf_result = informational | informational | false |
| GPO vs. Baseline | Abweichung | policy_deviation | verdict = deviation | fail | false |
| GPO vs. Baseline | Uebereinstimmung | policy_deviation | verdict = match | pass | false |
| GPO vs. Baseline | Setting fehlt beim Subjekt | policy_deviation | verdict = missing_on_subject | fail | false |
| GPO vs. Baseline | Setting fehlt beim Subjekt | policy_deviation | verdict = missing_on_subject | informational | false |
| GPO vs. Baseline | Setting nur beim Subjekt | policy_deviation | verdict = extra_on_subject | warning | false |
| GPO vs. Baseline | nicht vergleichbar | policy_deviation | verdict = not_comparable | unknown | false |
@ -404,9 +404,37 @@ Vertrags wird bei Bedarf daraus abgeleitet, nicht umgekehrt gespeichert.
`ioe_derivation_method` sowie `exposure_state` sind additive Spalten auf der
bestehenden Occurrence-Tabelle (`assessor_finding_occurrences`,
Migration `008_result_contract_facets.sql`), nicht Spalten einer neuen
Haupttabelle. `result_compliance` und `result_deviation` als eigene Blöcke
wurden nicht angelegt, da für beide noch kein Producer existiert
(CIS-CAT-Formatentscheidung offen, GPO-/Baseline-Submodell deferiert).
Haupttabelle. `result_compliance` und `result_deviation` wurden nicht als neue
Haupttabellen angelegt. GPO-/Baseline-Vergleiche stammen aus den versionierten
Policy-Evaluation-Entitäten und werden nur im Read-Modell vereinheitlicht.
### Datenschutzgrenze der Result DB
Die Result DB ist kein Rohdaten- oder Kundenkontextspeicher. Kanonische Findings
bleiben mit ihren allgemeinen Regeltexten, Bewertungen und Frameworkreferenzen
sichtbar; verknuepfte Results, originale Tabellenfelder und vollstaendige
Scannerkontexte bleiben ausserhalb dieser Projektion erhalten.
Ergaenzende Scannerbeobachtungen durchlaufen vor der Unified-Result-Projektion
die versionierte, fail-closed Disclosure Policy
`result-db-supplemental-disclosure` 1.0.0. Eine Beobachtung wird nur ausgegeben,
wenn Werkzeug, Property und ein enger Datentyp-/Wertebereich ausdruecklich
freigegeben sind. Titel, Subject-Klasse und Property werden aus der kontrollierten
Regel neu aufgebaut. Subject-Identitaet, Originalwert, Attribute, betroffener
Assetname, Abschnitt und Source Locator werden niemals aus der Beobachtung in
die Result DB uebernommen. Unbekannte Werkzeuge und neue Zusatzfelder bleiben
vollstaendig in der internen Persistenz, sind aber bis zu einer bewussten
Klassifikation nicht Teil der Result DB. Das betrifft nicht ihre regulaeren
kanonischen Findings.
Rohe GPO-Policy-Beobachtungen sind ebenfalls kein Result-DB-Inhalt. Sie bleiben
im expliziten `internal-analysis`-Lesemodus fuer Work und GPO Analysis erhalten;
versioniert evaluierte Soll-/Ist-Vergleiche erreichen die normale Result DB als
`policy_deviation`. Ohne freigegebene Risikoregel bleibt die Severity `unknown`;
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
Environment-Zeile eine Disclosure-Klassifikation und entfernt die gesperrten
Felder nochmals unmittelbar vor der Serialisierung.
### Asset-/Kantenschicht: Struktur ohne Producer

View File

@ -7,7 +7,7 @@ Status: Policy-Finding- und Read-Vertikalschnitt umgesetzt; Gesamtmodell in Arbe
## Zweck
Das gemeinsame Result-Read-Model stellt Scanner-Finding-Vorkommen und
nachvollziehbar erzeugte bestätigte Policy-Abweichungen über eine einheitliche,
nachvollziehbar erzeugte Policy-Baseline-Vergleiche über eine einheitliche,
nicht schreibbare Abfragegrenze bereit. Es ersetzt weder das kanonische
Finding-Modell noch die GPO-, Baseline- oder Evaluationsentitäten.
@ -24,7 +24,7 @@ Finding-Modell noch die GPO-, Baseline- oder Evaluationsentitäten.
| Scannerpipeline | erfüllt | Formatadapter, Source Extractor, Candidate-Mapping und kanonische Persistenz sind getrennt | keine werkzeugspezifische Resultpipeline ergänzen |
| GPO-Ingestion | teilweise | technische Identität, Baselineanalyse, Snapshotaggregation, Evaluation und Finding Factory sind vorhanden | freigegebene Inbox-ADMX-/Regelkataloge und UI fehlen |
| PostgreSQL | erfüllt für den Read-Pfad | Migrationen 001–017 persistieren Scanner-, Policy-Evaluation- und Policy-Finding-Provenienz | vollständig SQL-seitige Facetten bleiben optional |
| Repository/API | erfüllt | gemeinsame Result-, Facetten-, Detail- und Kategorienqueries lesen Scanner- und bestätigte Policy-Findings in Local, PostgreSQL, HTTP und Client | UI-Anbindung bleibt offen |
| Repository/API | erfüllt | gemeinsame Result-, Facetten-, Detail- und Kategorienqueries lesen Scanner-Findings, neutrale Policy-Vergleiche und angereicherte Policy-Findings in Local, PostgreSQL, HTTP und Client | UI-Anbindung bleibt offen |
| UI-Filter | teilweise | generische dynamische Dataset-Facetten sind vorhanden | serverseitige gemeinsame Resultfacetten sind noch nicht angebunden |
Gefährliche Doppelimplementierungen wären insbesondere eine zweite Scanner-
@ -74,13 +74,19 @@ Quelle -> Formatadapter -> DecodedSourceDocument -> Source Extractor
```text
GPO-Backup -> GPOBackupInstance -> PolicyObservation -> PolicyIdentity
-> EvaluationRun -> PolicyEvaluationResult
-> Policy Finding Factory -> Policy Finding Occurrence
-> Unified Result Projection
-> optional: Policy Finding Factory -> angereicherte Projektion
```
Die Policyseite projiziert ausschließlich vollständig belegte, bereits als
`eligible` bestätigte konfigurierte Abweichungen. Review- und Lückenfälle
bleiben außerhalb der Finding-Projektion.
Die Evaluation vergleicht eine technisch eindeutig aufgelöste Kundeneinstellung
neutral direkt mit dem Wert der importierten, eindeutig passenden Baseline. Fehlt
eine zusätzliche Katalogregel vollständig, ist ein typgetreuer Equality-Vergleich
zulässig; er erzeugt Matches oder konfigurierte technische Abweichungen, aber
keine Severity und keine Finding-Freigabe. Jeder deterministische Vergleich wird
direkt als `policy_deviation` unter dem Source Tool `GPOs` projiziert. Die
Policy-Finding-Projektion ersetzt die neutrale Zeile nur bei vollständig belegten,
durch eine freigegebene Risikoregel als `eligible` bestätigten Abweichungen und
ergänzt dann Severity sowie Risikokontext.
## Statusdimensionen
@ -120,8 +126,10 @@ Die API stellt Results, Facetten, Kategorien und Details unter `/api/v1/results`
- Die PostgreSQL-Read-Projektion leitet Kategorien derzeit weiterhin aus den
versionierten Domainobjekten ab; die relationale Struktur ist für den
folgenden vollständig SQL-seitigen Filterpfad vorbereitet.
- Der produktive Policy-Regelkatalog ist bis zur fachlichen Kuratierung leer;
deshalb entstehen aus dem realen Bestand weiterhin keine Policy-Findings.
- Der produktive Policy-Regelkatalog ist bis zur fachlichen Kuratierung leer.
Neutrale Baseline-/GPO-Vergleiche funktionieren ohne ihn; Policy-Findings,
Severity, stärker/schwächer und Abhängigkeitsauswertung entstehen weiterhin
erst nach fachlicher Freigabe entsprechender Regeln.
- Konflikte, Katalog-/Parserlücken, partielle Analysen und unaufgelöste
Identitäten benötigen noch eine gemeinsame UI-Darstellung.
- Lebenszykluswerte aus Vergleichen werden im ersten Schnitt nicht erfunden.

View File

@ -1,8 +1,17 @@
# GPO-/Baselineanalyse – Bedrohungsmodell und Speichergrenze
Stand: 2026-08-04
Stand: 2026-09-06
## Tooluebergreifende AI search (Phase 7, 2026-09-06)
Die ausdrueckliche Nutzerfreigabe erweitert die Suche auf alle FindingReader-Tools. Der lokale, quellenbelegte Public-Identifier-Katalog ergaenzt die bisherigen GPO-Begriffe um exakte Registryziele, Windows-Rechte, ASR- und Scanner-Regelkennungen. Exportiert wird ausschliesslich die katalogeigene Schreibweise oder festes Fachvokabular; kein ungepruefter Berichtstext. Eng begrenzte numerische/boolesche Baselineziele werden nur fuer erkannte Einstellungen und als ausgewaehlter, nicht unabhaengig verifizierter Benchmarkwert weitergegeben. Kundenseitige Istwerte, Konten/SIDs, Namen, Quellpfade, freie Beschreibungen/Abhilfe und importierte URLs bleiben ausgeschlossen. Unbekannte Regeln bleiben als Topic-Suche gekennzeichnet. Keine automatische Anfrage oder Uebernahme von AI-Antworten in Bewertungen. Dies praezisiert/erweitert die historischen Freigaben unten; Details, Grenzen und Katalogpflege siehe `public-ai-search.md`. Google-Verbindungs-/Kontodatenverarbeitung und betriebliche Freigaben bleiben unveraendert relevant.
Phase: 13B, verifiziert bis zum Registry.pol-Parser sowie zur Job- und Ergebnispersistenz
## AppLocker-Wertdarstellung (Phase 7, 2026-09-06)
Die reine Leseprojektion formatiert AppLocker-XML mit dem vorhandenen SAX-Parser als escapeten Text. Grenzen: 262.144 Zeichen, 2.000 Elemente, 32 Ebenen, 128 Attribute je Element. DTD, Processing Instructions, fremde Namespaces und fehlerhaftes XML werden nicht strukturiert dargestellt; der unveraenderte Wert bleibt inerte Textausgabe. Bedingungen, Ausnahmen und unbekannte Erweiterungen bleiben getrennt erhalten. Keine aktive HTML-Darstellung, externen Referenzabrufe, Bewertungsaenderung oder Mutation der Originaldaten.
## Schutzgüter
- Vertraulichkeit unveränderter GPO- und Baselineoriginale
@ -13,6 +22,14 @@ Phase: 13B, verifiziert bis zum Registry.pol-Parser sowie zur Job- und Ergebnisp
## Vertrauensgrenzen
Conflict-Results-Leseprojektion (2026-09-06): Aus vorhandenen normalisierten Beobachtungen abgeleitete Konfliktvarianten und Quellreferenzen werden im bestehenden Results-Fenster ausschliesslich als escapete Tabellenzellen/Praeambel dargestellt, niemals als HTML, Markdown-Links oder aktive Pfade. Der interne JSON-Umschlag wird vor Darstellung auf Version, Form, zwei Tabellen, maximal acht Spalten/20.000 Zeilen je Tabelle und 10 MiB Text begrenzt; bei Fehlern bleibt die urspruengliche Evidenzansicht bestehen. Whitespace wird im Umschlag escapet, damit die generische Tabellenprojektion signifikante Wertunterschiede nicht verwischt. Keine neue externe Navigation, Uebermittlung, Datenbankmutation oder Vergleichsentscheidung.
Erweiterung AI-Suchkontext (2026-09-06, Phase 7): Auf ausdruecklichen Nutzerwunsch werden Scope (computer/user), exakt freigegebene oeffentliche Baselinebezeichnungen und feste Vergleichslabels als anonymes Backup-Pruefszenario uebermittelt. Diese Freigabe ersetzt die vorherige vollstaendige Auslassung dieser Kontextfelder. Audit-Abhilfe fuer bekannte Microsoft-Subkategorien System Integrity/Logoff wird nur aus einer vollstaendig passenden, begrenzten Grammatik mit zwei eindeutigen booleschen Sollflags und passendem Titel/Scope neu aufgebaut; Expected und Must include bleiben getrennt. Unbekannte IDs, Zusatztexte, mehrdeutige Felder und andere freie Abhilfetexte werden verworfen. Description-Platzhalter sowie nicht unabhaengig verifizierte importierte Beschreibungen bleiben ausgeschlossen. Keine automatische Uebermittlung, keine Kundennamen/Istwerte/Quellpfade und keine Live-Suche mit Test- oder Kundendaten. Oeffentliche Fachbegriffe allein sind keine Zusicherung von DSGVO-Konformitaet: Google verarbeitet weiterhin Verbindungs-/IP- und gegebenenfalls Kontodaten; betriebliche Freigaben bleiben erforderlich. Historische Schutzentscheidungen gelten ausserhalb dieser engen Nutzerfreigabe unveraendert.
Praezisierung AI search (2026-09-06): Auf ausdruecklichen Nutzerwunsch ersetzt ein Google-AI-Mode-Browserlink (`search?udm=50&q=...`) die klassische Suchnavigation. Der Prompt verlangt offizielle Microsoft-Quellen, genaue Konfigurationshinweise und versionsabhaengige Baselineempfehlungen; dies ist eine Anfrage an Google, keine Garantie ueber die Antwortqualitaet oder Quellenauswahl. Eine feste Liste oeffentlicher Firewall-Einstellungsnamen, Domain-/Private-/Public-Profilbezeichnungen und Microsoft-Produktversionen ergaenzt die bestehenden freigegebenen Begriffe. Nur exakte Einstellungsnamen werden uebernommen, indem auf die fest hinterlegte oeffentliche Schreibweise abgebildet wird; rohe Metadaten bleiben lokal. Unbekannte Namen fallen auf das Fachthema zurueck. Keine Aenderung an Opener-/Referrerschutz oder Klickpflicht; keine API-/Aboanbindung, keine automatische Pruefung privater Google-Kontodaten. Google kann AI Mode je nach Konto/Region sperren oder den Deep Link aendern; Suchantworten werden nicht in Assessor-Bewertungen uebernommen.
Nutzerfreigegebene Websuche (2026-09-06): Der GPO-FindingReader darf ausschliesslich nach einem bewussten Klick eine HTTPS-Suche bei Google mit `site:learn.microsoft.com` oeffnen. Die Query wird nur aus fest im Quellcode freigegebenem oeffentlichem Microsoft-Vokabular aufgebaut. Importierte Finding-/GPO-/Kunden-/Baseline-Namen, Ist-/Sollwerte, Registry-/Datei-/Netzwerkpfade, Beschreibungen, URLs und Identitaeten werden niemals in die Query kopiert. Fuer exakt bekannte oeffentliche Einstellungsnamen existiert eine kleine feste Zuordnung; ansonsten wird ein allgemeines Fachthema gesucht. `noopener`, `noreferrer` und `referrerPolicy=no-referrer` verhindern Opener-/Referrerweitergabe. Keine automatische Anfrage, Telemetrie, Vorabpruefung, Such-API oder Uebertragung von Uploads. Der Suchanbieter erhaelt technisch weiterhin die Browserverbindung/IP und ggf. seine eigenen Cookies; keine Zusage anonymer Nutzung. ADML-Beschreibungen und abgeleitete Abhilfe werden nur als escapeter Text gerendert, nicht als HTML oder ausfuehrbare Links. Diese ausdrueckliche Freigabe erweitert nicht die uebrigen lokalen Verarbeitungsgrenzen.
Alle Uploadbytes, Dateinamen, Browser-Relativpfade, Archivpfade, XML-Inhalte, Backupmetadaten, Zeitstempel und Herstellerpakete sind nicht vertrauenswürdig. Der Browser, Dateiendungen und Client-MIME sind keine Autorität. Das Backend validiert Bytes, Hash, Pfade, Größen, Beziehungen und Benutzerentscheidungen erneut. PostgreSQL speichert Fachzustand; Originalbytes liegen getrennt außerhalb des Webroots hinter einer backendkontrollierten Speicher-ID.
Ein Parserworker begrenzt Zeit und Speicher, isoliert aber keinen bösartigen nativen Code. Importierte Programme, Skripte, Bibliotheken und Makros werden niemals geladen oder ausgeführt. Ein später optionaler externer Adapter benötigt einen administrativ freigegebenen festen Pfad und Hash, einen niedrig privilegierten Prozess ohne Shell und Netzwerk sowie harte Zeit-, Umgebungs- und Ausgabegrenzen.
@ -59,6 +76,9 @@ Ein unsicherer Pfad, eine Kollision, ein Link, eine Grenzverletzung, ein Hashfeh
## Verifizierte technische Kontrollen
- Nachtrag 2026-09-06: Metadaten-XML verwendet einen SAX-Parser ohne DOM, mit maximal 64 MiB je Datei, 500.000 Elementen, Tiefe 128 und begrenztem Identitaetsfeldpuffer. Das gemeinsame selektive Entpackbudget betraegt hoechstens 128 MiB; globale ZIP-, Datei-, Workerzeit- und Heapgrenzen bleiben erhalten. Die fruehere Anforderung an ein schliessbares Metadaten-JSDOM entfaellt; Preference-DOMs bleiben unveraendert. Synthetischer Lasttest: 2300 GPOs ueber ZIP und Ordnertransport, einschliesslich einer Metadatendatei groesser als 2 MiB.
- Nachtrag 2026-09-06: Baselinepakete lesen auch Security-Template- und Advanced-Audit-Artefakte durch dieselben begrenzten Parser/Reducer wie Kundenbackups. Native Abschnitt-/Schluesselidentitaeten ersetzen keine Registry- oder Targetingsemantik. Automatische Profilableitung bleibt auf gespeicherte Beobachtungen desselben Assessments begrenzt; unbekannte Plattformen und mehrere Snapshotgruppen erzeugen keine erfundene Freigabe. Bestehende Vollstaendigkeits-, Konflikt-, Risiko- und Finding-Grenzen gelten unveraendert.
- Der persistente GPO-Statusendpunkt prüft den Mandanten und gibt je GPO-Artefakt nur technischen Dateinamen, Größe, Queue-/Processingzustand, Versuche, Zeitpunkte, Fehlercode und Backupinstanzzahl aus. Die Mandantenansicht lädt diese Werte neu und pollt nur solange sie sichtbar ist; Speicherschlüssel und Rohinhalte werden nicht ausgegeben.
- Die Artefakt-/Jobanlage ist in lokaler und PostgreSQL-Persistenz atomar. PostgreSQL-Rollback, unmittelbares Entfernen einer neuen Dateikopie bei Commitfehler, idempotente Reparatur eines Legacy-Artefakts ohne Job sowie alters- und mengenbegrenztes Entfernen eines synthetischen unreferenzierten Objekts sind verifiziert. Reconciliation startet mit dem Backend und läuft standardmäßig stündlich; unbekannte Namen, Links und junge Objekte werden nicht gelöscht; bei leerem vertrauenswürdigem Referenzbestand findet überhaupt keine Objektlöschung statt.

View File

@ -0,0 +1,42 @@
# GPO comparison contract
## Microsoft evidence reviewed on 2026-09-06
- [Microsoft Security Compliance Toolkit](https://www.microsoft.com/en-us/download/details.aspx?id=55319): official PolicyAnalyzer.zip, bundled **Policy Analyzer.pdf**, version 4.0, pages 1 and 6. Read directly from the official ZIP in memory; no executables run.
- [Policy Analyzer introduction](https://techcommunity.microsoft.com/blog/microsoft-security-baselines/new-tool-policy-analyzer/701049).
- [ControlPolicyConflict / MDMWinsOverGP](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-controlpolicyconflict).
- [Windows Hello for Business policy conflicts](https://learn.microsoft.com/en-us/windows/security/identity-protection/hello-for-business/configure#policy-conflicts-from-multiple-policy-sources).
Policy Analyzer distinguishes differing configured values from absent settings. Repeated definitions of the same setting are indicated separately. The comparison retains registry data types: a numeric 5 and a text "5" differ. It can compare policy sets and local effective state; the latter is a separate operation.
## User-facing outcomes
| Outcome | Evidence required |
| --- | --- |
| Fail / Misconfigured | Explicit nonempty Microsoft recommendation, an actual configured customer value and a persisted deterministic mismatch. |
| Not configured | Explicit nonempty Microsoft recommendation and a missing customer setting; informational, excluded from Fail and Deviation. |
| Pass | Explicit Microsoft recommendation and a persisted deterministic match with an actual customer value. |
| Conflicts | Multiple distinct typed customer values for the same setting, or explicitly recorded contradictory recommendations within one baseline set. |
| Duplicates | Multiple source entries configuring the same setting to the same typed value. |
Registry addresses include computer/user scope (implicit HKLM/HKCU in Registry.pol), key and value name. Address matching ignores case; value data retains case and type. Different value names under the same key are distinct settings. Native security-template and advanced-audit settings use their resolved identities. Assessments and projects are never merged for collision detection.
Microsoft also calls configured differences *between* policy sets conflicts. ASSESSOR labels configured customer-versus-benchmark mismatches Fail. The user's 2026-09-06 correction replaces the earlier four-outcome contract: missing settings remain a separate informational outcome. GPO Deviation includes only Misconfigured, not Conflicts, Duplicates, missing values or review-only records. Other scanner deviation semantics remain unchanged. A conflict in the imported set is not a claim that both settings apply to the same endpoint: GPO backup analysis does not establish resultant policy.
One baseline comparison creates one Work row with all source references. One customer collision group creates one Conflicts/Duplicates row. A duplicate group may also have a separate Pass/Fail benchmark result; these are different questions. Multiple benchmarks do not themselves create duplicate customer settings.
Unsupported, unbound, not-evaluated, and ordinary review-only observations do not create Work rows. Diagnostics and original evidence remain available separately. Existing persisted data is not deleted or reclassified by this presentation change.
## Hybrid AD / Intune
A GPO and an Intune policy can prescribe conflicting values for an equivalent setting. This requires both policy sources, device/user assignments, an explicit CSP-to-GPO mapping, and applicable precedence rules. Matching raw registry strings is insufficient because equivalent GPO and CSP settings may use different locations.
MDMWinsOverGP is device-scoped, defaults to 0, and governs Policy CSP equivalents only. It is not a universal MDM priority rule. Microsoft documents separate behavior for PassportForWork/Windows Hello for Business, where mixed GPO/CSP configuration can prevent CSP settings from applying. MDM diagnostics can report GP settings blocked by an MDM equivalent.
The current importer does not provide Intune policy settings and assignment evidence. Hybrid status from another report alone cannot establish such a conflict. Hybrid conflict evaluation therefore remains an explicit future input/mapping task; no synthetic Intune conflict is inferred from GPO backups.
## Verification and remaining parity work
Synthetic regression coverage includes absent/empty baselines, missing customer values, typed collisions, address case, different value names, scope and timeslot isolation, baseline separation, duplicate read rows, source preservation, and a 2300-GPO group. Work and the analysis summary use the same builder.
This is not a claim of complete executable parity with Policy Analyzer. A differential run of the same synthetic packages through Policy Analyzer and ASSESSOR remains required, including cross-format Registry.pol/security-template equivalence, ordered delete instructions, supported type families and translated identifiers. Conditional Group Policy Preferences and resultant endpoint policy require additional semantics/evidence and stay excluded from automatic claims.

View File

@ -0,0 +1,25 @@
# Public AI search
Presentation-only, Phase 7. The shared FindingReader exposes AI search for every tool, including mixed-tool workspaces. An explicit click opens Google AI Mode with opener/referrer protection. No background lookup, runtime catalog download, automatic AI evaluation or report upload is introduced.
## Public evidence boundary
`src/profiles/public-search/catalog.json` is a public-identifier snapshot reviewed on 2026-09-06: 67 registry targets, 45 privilege constants, 19 ASR identifiers, 161 Maester IDs and 51 PingCastle IDs. Source URLs accompany the identifiers. It is not a complete or automatically updated vendor catalog. It contains identifiers, not copied vendor descriptions or customer report prose. Noncanonical source notations (query-style value names or ambiguous path separators) are excluded rather than guessed.
- Registry matches require the complete canonical key plus value name; only the catalog-owned spelling is exported. Private extensions do not match.
- Native sections and keys resolve to public security-policy identities. ASR identifiers also require the Defender ASR registry family.
- Scanner IDs must exist in the tool-specific snapshot. Dynamic instance suffixes are discarded.
- CIS-CAT, Purple Knight, generic and unresolved rules use locally recognized fixed technical concepts. Only fixed vocabulary is sent, not the input excerpt. Registry lookup is tool-independent.
- Public product families and exact scope/status enums add context without proving OS/role applicability. Existing exact GPO baseline/audit context remains available.
- An identified numeric/boolean setting may include a bounded selected benchmark target (integer 0–65535 or boolean). It is labeled as selected, not independently verified as a Microsoft recommendation. Assigned accounts, SID lists, strings, arbitrary JSON and all customer actual values are excluded.
- Imported descriptions, remediation, locators, identities, URLs, owner information and free-form titles are never passed through. Imported ADML provenance alone does not establish that arbitrary prose is public.
The search requests the exact control, purpose, risk, configuration steps, verification and direct primary-source links, in German. Azure registry identity references do not replace the selected SCT benchmark or determine its expected value. AI output never changes findings or grading.
## Incomplete evidence and maintenance
The link exposes its specificity (`setting`, `rule`, `topic`) and full query in its tooltip. Unresolved cases explicitly request topic discussion rather than an invented rule or recommendation. No promise of complete coverage, vendor-verbatim guidance, anonymity or GDPR compliance is made. Google handles connection/IP and potentially account/cookie data; organizational approval remains necessary.
Extend the catalog only from independently reviewed public publisher sources, with attribution and synthetic tests. Never learn public vocabulary automatically from customer reports. Tests cover all tool links, mixed workspaces, private suffixes, complete path matching, numeric bounds, ambiguous fields and catalog integrity. Tests do not query Google or inspect real tenant content.
GPO badges use existing section/type and registry family evidence. System Access, Privilege Rights, Registry Values and related sections are display categories, not new outcomes. Registry/Software/System are structural fallbacks; specific product/security families take precedence. Description text does not assign badges.

View File

@ -7,7 +7,7 @@
http-equiv="Content-Security-Policy"
content="__ASSESSOR_CSP__"
/>
<link rel="icon" type="image/png" sizes="128x128" href="/src/assets/branding/assessor-icon-dark-128.png" />
<link rel="icon" type="image/png" href="/src/assets/branding/assessor-icon-transparent.png" />
<title>ASSESSOR</title>
</head>
<body>

4
package-lock.json generated
View File

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

View File

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

View File

@ -25,11 +25,13 @@ import type { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service
import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility';
import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release';
import { compareReimportSourceHashes } from '../src/domain/reimport-source';
import {
findPossibleDuplicateImportFile,
@ -129,6 +131,7 @@ export interface AssessorHttpServerOptions {
gpoPolicyEvaluationService?: GpoPolicyEvaluationService;
gpoPolicyFindingService?: GpoPolicyFindingService;
gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService;
automaticGpoAnalysis?: GpoAutomaticAnalysisService;
microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService;
}
@ -230,6 +233,7 @@ function unifiedQueryFrom(url: URL): UnifiedResultQuery {
return undefined;
};
return {
audience: url.searchParams.get('audience') === 'internal-analysis' ? 'internal-analysis' : 'result-db',
projectId: url.searchParams.get('projectId') ?? undefined,
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
assessmentIds: many('assessmentIds'),
@ -389,7 +393,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (method === 'GET' && segments.length === 1 && segments[0] === 'health') {
const result: AssessorHealthResponse = {
status: 'ok', application: 'ASSESSOR', apiVersion: 1,
status: 'ok', application: 'ASSESSOR', productVersion: ASSESSOR_PRODUCT_VERSION, apiVersion: 1,
releaseId: options.releaseId,
developmentInstanceId: options.developmentInstanceId,
repositoryRevision: repository.revision, persistence: options.persistence ?? 'memory'
@ -740,6 +744,16 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
status = 200; sendJson(response, status, { version: 1, record }, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 5 && segments[4] === 'evaluate' && method === 'POST') {
if (!options.automaticGpoAnalysis) throw new HttpError(503, 'gpo_evaluation_unavailable', 'GPO evaluation unavailable.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
const artifact = (await repository.listImportArtifacts(segments[1])).find(item => item.id === segments[3]);
if (!artifact) throw new RepositoryNotFoundError('GPO source missing.');
const runs = (await repository.listProcessingRuns(artifact.id)).filter(item => item.projectId === segments[1])
.sort((a, b) => b.completedAt.localeCompare(a.completedAt) || b.id.localeCompare(a.id));
if (!runs[0]) throw new HttpError(409, 'gpo_processing_required', 'Import processing required.');
status = 200; sendJson(response, status, await options.automaticGpoAnalysis.run(segments[1], runs[0].id), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 5 && segments[4] === 'reprocess' && method === 'POST') {
if (!options.gpoBackupManagementService) throw new HttpError(503, 'gpo_management_unavailable', 'Die GPO-Verwaltung ist nicht konfiguriert.');
const body = await readJson<{ expectedLatestProcessingRunId: string }>(request, maximumBodyBytes);
@ -759,7 +773,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (!options.gpoBackupManagementService) throw new HttpError(503, 'gpo_management_unavailable', 'Die GPO-Verwaltung ist nicht konfiguriert.');
const body = await readJson<{ artifactId: string; confirmDelete: true }>(request, maximumBodyBytes);
if (!body || body.confirmDelete !== true || body.artifactId !== segments[3]) throw new HttpError(400, 'gpo_delete_confirmation_invalid', 'Die Löschung wurde nicht für das exakte GPO-Artefakt bestätigt.');
const result = await options.gpoBackupManagementService.deleteFailed(segments[1], segments[3]);
const result = await options.gpoBackupManagementService.delete(segments[1], segments[3]);
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 4 && segments[3] === 'status' && method === 'GET') {
@ -780,7 +794,14 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const result: GpoBackupSubmissionResponse = await options.gpoBackupSubmissionService.submit({
projectId: segments[1], assessmentId: body.assessmentId, fileName: body.fileName, bytes, receivedAt: now().toISOString()
});
status = 202; sendJson(response, status, result, requestId); return;
const evaluation = result.deduplicated && options.automaticGpoAnalysis
? await (async () => {
const runs = (await repository.listProcessingRuns(result.artifactId))
.sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id));
return runs[0] ? options.automaticGpoAnalysis!.run(segments[1], runs[0].id) : undefined;
})()
: undefined;
status = 202; sendJson(response, status, { ...result, evaluation }, requestId); return;
}
if (segments.length === 1 && segments[0] === 'projects' && method === 'GET') {
status = 200; sendJson(response, status, await repository.listProjects(), requestId); return;

View File

@ -159,6 +159,7 @@ export function reduceAdvancedAuditPolicy(
const conflicted = distinctCodes.length > 1;
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId,
context.artifactId,
document.sourceSha256,
first.subcategoryGuid,
ADVANCED_AUDIT_REDUCER.version

View File

@ -0,0 +1,89 @@
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError } from '../../src/persistence/repository-errors';
import { inferGpoEnvironmentContexts } from './gpo-environment-context';
import { GpoPolicySnapshotService } from './gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from './gpo-policy-evaluation-service';
import { GpoPolicyFindingService } from './gpo-policy-finding-service';
import { buildGpoAnalysisSnapshots } from '../../src/gpo/snapshot-builder';
export type AutomaticGpoAnalysisResult = {
state: 'context-required' | 'baseline-required' | 'review' | 'evaluated';
evaluationRunId?: string;
diagnosticCode?: string;
};
/** One uploaded backup set, one timeslot. Never combines unrelated historical scans. */
export class GpoAutomaticAnalysisService {
private readonly active = new Map<string, Promise<AutomaticGpoAnalysisResult>>();
constructor(private readonly repository: AssessorRepository,
private readonly snapshots: GpoPolicySnapshotService,
private readonly evaluations: GpoPolicyEvaluationService,
private readonly findings: GpoPolicyFindingService) {}
run(projectId: string, processingRunId: string): Promise<AutomaticGpoAnalysisResult> {
const key = `${projectId}:${processingRunId}`;
const existing = this.active.get(key);
if (existing) return existing;
const operation = this.execute(projectId, processingRunId).finally(() => this.active.delete(key));
this.active.set(key, operation);
return operation;
}
async reprocessProject(projectId: string): Promise<{ evaluated: number; review: number; failed: number }> {
let evaluated = 0;
let review = 0;
let failed = 0;
const analyses = await this.repository.listGpoPolicyAnalyses(projectId);
const superseded = new Set(analyses.flatMap(item => item.run.supersedesAnalysisRunId
? [item.run.supersedesAnalysisRunId]
: []));
for (const analysis of analyses.filter(item => !superseded.has(item.run.id))) {
try {
const result = await this.run(projectId, analysis.run.sourceProcessingRunId);
if (result.state === 'evaluated') evaluated += 1;
else review += 1;
} catch {
failed += 1;
}
}
return { evaluated, review, failed };
}
private async execute(projectId: string, processingRunId: string): Promise<AutomaticGpoAnalysisResult> {
const analysis = (await this.repository.listGpoPolicyAnalyses(projectId))
.find(record => record.run.sourceProcessingRunId === processingRunId);
if (!analysis) throw new RepositoryNotFoundError('GPO analysis missing.');
const artifact = (await this.repository.listImportArtifacts(projectId)).find(record => record.id === analysis.run.artifactId);
if (!artifact?.assessmentId) return { state: 'context-required' };
const environment = await this.repository.getEnvironmentSnapshot({ projectId, assessmentId: artifact.assessmentId });
const observedContexts = inferGpoEnvironmentContexts(environment.observations);
const contexts = [...observedContexts, {
id: 'gpo-baseline-comparison', kind: 'operating-system' as const,
productFamily: 'Group Policy', productVersion: 'All imported baselines', role: 'Any', scope: 'mixed' as const
}].filter((context, index, all) => all.findIndex(candidate => candidate.id === context.id) === index);
const instances = (await this.repository.listGpoBackupInstances(projectId))
.filter(item => item.processingRunId === processingRunId);
const snapshotCandidates = buildGpoAnalysisSnapshots(instances.map(item => item.instance), { createdAt: analysis.run.completedAt });
if (snapshotCandidates.length !== 1) return { state: 'review', diagnosticCode: 'policy-aggregation-multiple-snapshots' };
const profile = await this.snapshots.createEnvironmentProfile(projectId, {
name: `Detected environment · ${artifact.assessmentId}`, contexts
});
const baselines = await this.repository.listMicrosoftSecurityBaselines();
const eligibleBaselineIds = new Set(baselines.filter(record => record.status === 'eligible').map(record => record.id));
if (!eligibleBaselineIds.size) return { state: 'baseline-required' };
const analyses = await this.repository.listMicrosoftSecurityBaselineAnalyses();
const superseded = new Set(analyses.flatMap(item => item.supersedesAnalysisId ? [item.supersedesAnalysisId] : []));
const baselineAnalysisIds = analyses.filter(item => eligibleBaselineIds.has(item.analysis.baselineId)
&& !superseded.has(item.analysis.id))
.map(item => item.analysis.id).sort();
if (!baselineAnalysisIds.length) return { state: 'baseline-required' };
const aggregation = await this.snapshots.aggregate(projectId, {
environmentProfileId: profile.id, sourceAnalysisRunIds: [analysis.run.id]
});
const evaluation = await this.evaluations.evaluate(projectId, {
aggregationRunId: aggregation.run.id, baselineAnalysisIds: [...new Set(baselineAnalysisIds)].sort()
});
await this.findings.create(projectId, { evaluationRunId: evaluation.run.id });
return { state: evaluation.run.status === 'complete' ? 'evaluated' : 'review', evaluationRunId: evaluation.run.id };
}
}

View File

@ -87,7 +87,8 @@ export function ingestGpoBackupContainer(bytes: Uint8Array, options: GpoBackupIn
bytes,
selectedPaths,
options.resourcePolicy,
options.maximumSelectedArtifactBytes ?? options.maximumMetadataBytes ?? 16 * 1024 * 1024
options.maximumSelectedArtifactBytes ?? options.maximumMetadataBytes
?? Math.min(options.resourcePolicy?.maximumZipUncompressedBytes ?? 512 * 1024 * 1024, 128 * 1024 * 1024)
);
const retainIntermediateDocuments = options.retainIntermediateDocuments ?? true;
const metadataPathSet = new Set(metadataPaths);

View File

@ -9,13 +9,14 @@ import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service';
import type { GpoBackupWorkerExecutor } from './gpo-inventory-executor';
export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.0.0';
export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.2';
export interface GpoBackupJobProcessorOptions {
leaseDurationMs?: number;
retryDelayMs?: number;
maximumArtifactBytes: number;
now?: () => Date;
afterAnalysis?: (projectId: string, processingRunId: string) => Promise<unknown>;
}
export interface GpoBackupJobProcessResult {
@ -47,7 +48,7 @@ export class GpoBackupJobProcessor {
private readonly repository: AssessorRepository,
private readonly artifactStore: BackendArtifactStore,
private readonly executor: GpoBackupWorkerExecutor,
options: GpoBackupJobProcessorOptions
private readonly options: GpoBackupJobProcessorOptions
) {
this.leaseDurationMs = options.leaseDurationMs ?? 60_000;
this.retryDelayMs = options.retryDelayMs ?? 5_000;
@ -61,10 +62,11 @@ export class GpoBackupJobProcessor {
}
}
async processNext(workerId: string): Promise<GpoBackupJobProcessResult | null> {
async processNext(workerId: string, projectId?: string): Promise<GpoBackupJobProcessResult | null> {
const claimedAt = this.now().toISOString();
const job = await this.repository.claimNextIngestionJob({
workerId,
projectId,
now: claimedAt,
leaseDurationMs: this.leaseDurationMs,
kinds: ['analyze-gpo-backup']
@ -75,6 +77,7 @@ export class GpoBackupJobProcessor {
if (!job.artifactId) throw new RepositoryValidationError('Der GPO-Analysejob besitzt kein Artefakt.');
const existingRun = (await this.repository.listProcessingRuns(job.artifactId)).find(run => run.id === processingRunId);
if (existingRun) {
await this.options.afterAnalysis?.(job.projectId, processingRunId);
const completed = await this.repository.completeIngestionJob(job.id, workerId, this.now().toISOString());
const existingInstances = (await this.repository.listGpoBackupInstances(job.projectId))
.filter(record => record.processingRunId === processingRunId);
@ -160,6 +163,7 @@ export class GpoBackupJobProcessor {
resolutions: storedResolutions
};
await this.repository.saveGpoBackupAnalysis(run, instances, policyAnalysis);
await this.options.afterAnalysis?.(job.projectId, processingRunId);
const completed = await this.repository.completeIngestionJob(job.id, workerId, this.now().toISOString());
return { jobId: job.id, status: completed.status, processingRunId, backupInstanceCount: instances.length };
} catch (error) {
@ -204,4 +208,3 @@ export class GpoBackupJobRunner {
await this.active;
}
}

View File

@ -45,7 +45,7 @@ export class GpoBackupManagementService {
supersedesProcessingRunId: latest.id
};
}
async deleteFailed(projectId: string, artifactId: string) {
async delete(projectId: string, artifactId: string) {
const artifact = (await this.repository.listImportArtifacts(projectId)).find(item => item.id === artifactId);
if (!artifact || artifact.storage.kind !== 'backend-controlled') throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.');
const timestamp = this.now().toISOString();
@ -59,7 +59,7 @@ export class GpoBackupManagementService {
nextAttemptAt: timestamp,
createdAt: timestamp
};
const deleted = await this.repository.deleteFailedGpoArtifact(projectId, artifactId, request);
const deleted = await this.repository.deleteGpoArtifact(projectId, artifactId, request);
const storageRemoval = await processArtifactDeletionRequest(this.repository, this.artifactStore, deleted.request, this.now);
return { version: 1 as const, artifactId, deleted: true as const, storageRemoval };
}

View File

@ -6,7 +6,7 @@ import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/pe
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import type { GpoInventoryExecutor } from './gpo-inventory-executor';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.0.0';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.2';
export interface SubmitGpoBackupRequest {
projectId: string;

View File

@ -0,0 +1,30 @@
import { createHash } from 'node:crypto';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import type { EnvironmentProfileContext } from '../../src/domain/gpo-snapshot-aggregation';
/** Observed platform inventory is evidence for candidate contexts, not GPO applicability. */
export function inferGpoEnvironmentContexts(observations: EnvironmentObservation[]): Array<Omit<EnvironmentProfileContext, 'version'>> {
const contexts = new Map<string, Omit<EnvironmentProfileContext, 'version'>>();
for (const observation of observations) {
if (observation.state !== 'observed' || observation.property !== 'operating_system.name') continue;
const value = observation.normalizedValue ?? observation.originalValue;
if (typeof value !== 'string') continue;
const isDc = observation.subject.type === 'computer-information.domain-controllers';
const server = /^Windows(?: Server)? (2008(?: R2)?|2012(?: R2)?|2016|2019|2022|2025)(?:\b|$)/i.exec(value.trim());
// A generic server inventory row does not prove member-server role.
if (!isDc || !server) continue;
const productVersion = server[1].toUpperCase();
const key = `Windows Server:${productVersion}:Domain Controller`;
const existing = contexts.get(key);
if (existing) {
existing.sourceObservationIds = [...new Set([...(existing.sourceObservationIds ?? []), observation.id])].sort();
continue;
}
contexts.set(key, {
id: 'detected-context-' + createHash('sha256').update(key).digest('hex').slice(0, 24),
kind: 'operating-system', productFamily: 'Windows Server', productVersion,
role: 'Domain Controller', scope: 'computer', sourceObservationIds: [observation.id]
});
}
return [...contexts.values()].sort((a, b) => a.id.localeCompare(b.id));
}

View File

@ -8,7 +8,7 @@ import {
} from './gpo-container-inventory';
import { ingestGpoBackupContainer, type GpoBackupIngestionOptions } from './gpo-backup-ingestion';
import type { GpoBackupInstance } from '../../src/domain/gpo-ingestion';
import { resolveRegistryPolicyIdentity } from './admx-policy-catalog';
import { resolvePolicyTarget } from './policy-target-resolver';
import {
analyzeMicrosoftSecurityBaselinePackage,
type MicrosoftSecurityBaselineAnalysisOptions
@ -88,9 +88,7 @@ export function analyzeGpoBackupForPersistence(request: GpoBackupWorkerExecution
const identityResolutions = analysis.backupInstances.flatMap(instance => (instance.policyObservations ?? []).map(observation => ({
backupInstanceId: instance.id,
observationId: observation.id,
resolution: observation.deliveryMechanism === 'registry-policy'
? resolveRegistryPolicyIdentity(observation)
: { version: 1 as const, status: 'unresolved' as const, reasonCode: `identity-resolver-unsupported-${observation.deliveryMechanism}` }
resolution: resolvePolicyTarget(observation)
})));
return {
version: 1,

View File

@ -1,11 +1,12 @@
import { JSDOM } from 'jsdom';
import { SaxesParser } from 'saxes';
import type { GpoBackupMetadataDocument } from '../../src/domain/gpo-metadata';
import type { ImportResourcePolicy } from '../../src/importers/resource-policy';
import { resolveImportResourcePolicy, validateDecodedTextResources } from '../../src/importers/resource-policy';
import { ImportError } from '../../src/shared/errors';
import type { VerifiedZipFile } from './zip-inventory';
const parser = { id: 'gpo-backup-metadata-xml', version: '1.0.0' } as const;
const parser = { id: 'gpo-backup-metadata-xml', version: '1.2.0' } as const;
const maximumMetadataBytes = 64 * 1024 * 1024;
const guidPattern = /^\{?([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\}?$/i;
const dnsPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
@ -14,7 +15,7 @@ function parserError(code: string, message: string, details: Record<string, stri
}
function decodeXml(bytes: Uint8Array): string {
if (bytes.byteLength > 2 * 1024 * 1024) parserError('gpo-metadata-size-limit', 'Eine GPO-Metadatendatei überschreitet das Größenlimit.', { sizeBytes: bytes.byteLength });
if (bytes.byteLength > maximumMetadataBytes) parserError('gpo-metadata-size-limit', 'Eine GPO-Metadatendatei überschreitet das Größenlimit.', { sizeBytes: bytes.byteLength });
let encoding = 'utf-8';
let content = bytes;
if (bytes[0] === 0xff && bytes[1] === 0xfe) { encoding = 'utf-16le'; content = bytes.subarray(2); }
@ -87,8 +88,8 @@ export function parseGpoBackupMetadataXml(
const text = decodeXml(file.bytes);
const policy = resolveImportResourcePolicy({
...resourceOverrides,
maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? 2 * 1024 * 1024, 2 * 1024 * 1024),
maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? 20_000, 20_000)
maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? maximumMetadataBytes, maximumMetadataBytes),
maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? 500_000, 500_000)
});
validateDecodedTextResources(text, 'xml', policy);
if (/<\s*(?:[A-Za-z_][\w.-]*:)?include\b/i.test(text)
@ -96,33 +97,51 @@ export function parseGpoBackupMetadataXml(
|| /<\?(?!xml(?:\s|\?>))/i.test(text)) {
parserError('gpo-metadata-external-reference-rejected', 'Externe Includes, Schemas und Verarbeitungsanweisungen sind in GPO-Metadaten nicht zulässig.');
}
let dom: JSDOM;
try {
dom = new JSDOM(text, { contentType: 'text/xml' });
} catch {
parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.');
}
const document = dom.window.document;
try {
if (document.getElementsByTagName('parsererror').length) parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.');
const diagnostics = new Set<string>();
const values = new Map<string, string[]>();
let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected';
for (const element of [...document.getElementsByTagName('*')]) {
const name = element.localName.toLocaleLowerCase('en-US');
if (name === 'cpassword' || [...element.attributes].some(attribute => attribute.localName.toLocaleLowerCase('en-US') === 'cpassword')) {
// Retain only bounded identity fields, never the full report tree or secret values.
const stack: Array<{ name: string; field?: string; selected: boolean; nested: boolean; text: string }> = [];
const xml = new SaxesParser({ xmlns: true });
xml.on('error', () => parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.'));
xml.on('doctype', () => parserError('gpo-metadata-external-reference-rejected', 'DTD ist in GPO-Metadaten nicht zulässig.'));
xml.on('opentag', element => {
if (stack.length >= 128) parserError('gpo-metadata-depth-limit', 'GPO-Metadaten sind zu tief verschachtelt.');
const parent = stack[stack.length - 1];
if (parent) parent.nested = true;
const name = element.local.toLocaleLowerCase('en-US');
if (name === 'cpassword' || Object.values(element.attributes).some(attribute => attribute.local.toLocaleLowerCase('en-US') === 'cpassword')) {
secretIndicatorStatus = 'present';
diagnostics.add('gpo-secret-indicator-present');
}
if (!['gpoid', 'gpoguid', 'backupid', 'id', 'gpodomain', 'gpodomainguid', 'domainname', 'backuptime'].includes(name)) continue;
if (name === 'id' && element.parentElement?.localName.toLocaleLowerCase('en-US') !== 'backupinst') continue;
const value = element.textContent?.trim() ?? '';
if (!value || value.length > 2048 || element.children.length) {
const path = [...stack.map(item => item.name), name].join('/');
const core = 'grouppolicybackupscheme/grouppolicyobject/grouppolicycoresettings/';
const field = path === core + 'id' || path === 'gpo/identifier/identifier' ? 'gpoid'
: path === core + 'domain' || path === 'gpo/identifier/domain' ? 'gpodomain'
: name === 'id' && parent?.name === 'backupinst' ? 'backupid'
: ['gpoid', 'gpoguid', 'backupid', 'gpodomain', 'gpodomainguid', 'domainname', 'backuptime'].includes(name) ? name : undefined;
stack.push({ name, field, selected: field !== undefined, nested: false, text: '' });
});
const onText = (value: string) => {
const current = stack[stack.length - 1];
if (current?.selected) current.text = (current.text + value).slice(0, 2049);
};
xml.on('text', onText);
xml.on('cdata', onText);
xml.on('closetag', () => {
const current = stack.pop()!;
if (!current.selected) return;
const value = current.text.trim();
if (!value || current.text.length > 2048 || current.nested) {
diagnostics.add('gpo-metadata-field-shape-invalid');
continue;
return;
}
values.set(name, [...(values.get(name) ?? []), value]);
}
const entries = values.get(current.field!) ?? [];
entries.push(value);
values.set(current.field!, entries);
});
for (let offset = 0; offset < text.length; offset += 64 * 1024) xml.write(text.slice(offset, offset + 64 * 1024));
xml.close();
const gpoId = uniqueGuid([...(values.get('gpoid') ?? []), ...(values.get('gpoguid') ?? [])], diagnostics, 'gpo-id');
const backupId = uniqueGuid([...(values.get('backupid') ?? []), ...(values.get('id') ?? [])], diagnostics, 'backup-id');
const domainKey = canonicalDomain(values, diagnostics);
@ -145,7 +164,4 @@ export function parseGpoBackupMetadataXml(
secretIndicatorStatus,
diagnosticCodes: [...diagnostics].sort()
};
} finally {
dom.window.close();
}
}

View File

@ -11,6 +11,7 @@ import {
export interface PolicyRuleResolution {
baselineState: PolicyBaselineState;
comparator: PolicyComparatorDefinition;
source: 'baseline-setting' | 'rule-catalog';
ruleIds: string[];
dependencyRuleIds: string[];
riskRuleIds: string[];
@ -73,6 +74,7 @@ export function resolvePolicyEvaluationRule(
): PolicyRuleResolution {
const diagnostics = new Set<string>();
const selected: PolicyEvaluationRule[] = [];
let missingContextRules = 0;
for (const contextId of state.applicableContextIds) {
const context = profile.contexts.find(candidate => candidate.id === contextId);
if (!context) {
@ -83,20 +85,25 @@ export function resolvePolicyEvaluationRule(
catalog, state.identity.id, state.deliveryMechanism, context, state.identity.scope
);
if (candidates.length !== 1) {
diagnostics.add(candidates.length
? 'policy-evaluation-rule-ambiguous'
: 'policy-evaluation-rule-catalog-gap');
if (candidates.length) diagnostics.add('policy-evaluation-rule-ambiguous');
else missingContextRules += 1;
continue;
}
selected.push(candidates[0]);
}
const rules = [...new Map(selected.map(rule => [rule.id, rule])).values()]
.sort((left, right) => left.id.localeCompare(right.id));
const useBaselineSetting = rules.length === 0
&& missingContextRules === state.applicableContextIds.length
&& !diagnostics.size;
if (!useBaselineSetting && missingContextRules > 0) {
diagnostics.add('policy-evaluation-rule-catalog-gap');
}
const dispositions = new Set(rules.map(rule => rule.baselineDisposition));
if (dispositions.size > 1) diagnostics.add('policy-evaluation-rule-context-conflict');
let baselineState: PolicyBaselineState = dispositions.size === 1
? rules[0].baselineDisposition
: 'catalog-gap';
: useBaselineSetting ? 'explicit-recommendation' : 'catalog-gap';
const recommendationRules = rules.filter(rule => rule.baselineDisposition === 'explicit-recommendation');
const comparatorSignatures = new Set(recommendationRules.map(rule => canonicalJson(rule.comparator)));
if (comparatorSignatures.size > 1) diagnostics.add('policy-evaluation-comparator-consensus-conflict');
@ -121,7 +128,8 @@ export function resolvePolicyEvaluationRule(
const comparator = recommendationRules[0]?.comparator ?? { kind: 'equality' as const };
return {
baselineState,
comparator,
comparator: useBaselineSetting ? { kind: 'equality' } : comparator,
source: useBaselineSetting ? 'baseline-setting' : 'rule-catalog',
ruleIds: rules.map(rule => rule.id),
dependencyRuleIds: dependencies.map(rule => rule.id),
riskRuleIds: sortedUnique(recommendationRules.flatMap(rule => rule.riskRule ? [rule.riskRule.id] : [])),

View File

@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { canonicalJson } from '../../src/shared/canonical-json';
import { baselineProductContexts, type MicrosoftSecurityBaselineRecord } from '../../src/domain/microsoft-security-baseline';
import type { MicrosoftSecurityBaselineRecord } from '../../src/domain/microsoft-security-baseline';
import type { StoredMicrosoftSecurityBaselineAnalysis, MicrosoftSecurityBaselineSetting } from '../../src/domain/microsoft-security-baseline-analysis';
import type { AggregatedPolicyState, EnvironmentProfile, StoredPolicySnapshotAggregation } from '../../src/domain/gpo-snapshot-aggregation';
import {
@ -23,7 +23,6 @@ import {
policyEvaluationRuleCatalogValidationErrors,
type PolicyEvaluationRuleCatalog
} from '../../src/domain/policy-evaluation-rules';
import { resolvePolicyEvaluationRule } from './gpo-policy-evaluation-rule-engine';
function stableId(prefix: string, value: string): string {
return prefix + '-' + createHash('sha256').update(value).digest('hex').slice(0, 32);
@ -33,19 +32,6 @@ function sortedUnique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
function normalized(value: string): string {
return value.trim().toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, ' ').trim();
}
function baselineMatchesContext(record: MicrosoftSecurityBaselineRecord, context: EnvironmentProfile['contexts'][number], scope: 'computer' | 'user'): boolean {
return baselineProductContexts(record.metadata).some(candidate =>
normalized(candidate.productFamily) === normalized(context.productFamily)
&& normalized(candidate.productVersion) === normalized(context.productVersion)
&& candidate.roles.some(role => normalized(role) === 'any' || normalized(role) === normalized(context.role))
&& (candidate.scopes.includes('mixed') || candidate.scopes.includes(scope))
);
}
interface EvaluationBuildInput {
profile: EnvironmentProfile;
aggregation: StoredPolicySnapshotAggregation;
@ -71,115 +57,77 @@ function evidenceFor(resultId: string, inputs: EvaluationEvidenceInput[]): Polic
})));
}
function resolvedSettings(analysis: StoredMicrosoftSecurityBaselineAnalysis, identityId: string): MicrosoftSecurityBaselineSetting[] {
return analysis.analysis.settings.filter(setting =>
setting.identity.status === 'resolved' && setting.identity.identity.id === identityId
).sort((left, right) => left.id.localeCompare(right.id));
}
function evaluateState(
function evaluateBaselineIdentity(
runId: string,
input: EvaluationBuildInput,
state: AggregatedPolicyState,
analysesById: Map<string, StoredMicrosoftSecurityBaselineAnalysis>,
recordsById: Map<string, MicrosoftSecurityBaselineRecord>
analysis: StoredMicrosoftSecurityBaselineAnalysis,
baseline: MicrosoftSecurityBaselineRecord,
settings: MicrosoftSecurityBaselineSetting[],
state: AggregatedPolicyState | undefined
): { result: PolicyEvaluationResult; evidence: PolicyEvaluationEvidence[] } {
const diagnostics = new Set<string>();
const rule = resolvePolicyEvaluationRule(input.ruleCatalog, input.profile, input.aggregation, state);
rule.diagnosticCodes.forEach(code => diagnostics.add(code));
let baselineState = rule.baselineState;
const applicableContexts = state.applicableContextIds.map(id => input.profile.contexts.find(context => context.id === id));
if (applicableContexts.some(context => !context)) diagnostics.add('policy-evaluation-context-missing');
if (input.aggregation.run.status !== 'complete' || input.aggregation.snapshot.completeness !== 'complete') {
diagnostics.add('policy-evaluation-snapshot-incomplete');
const identity = settings[0].identity.status === 'resolved' ? settings[0].identity.identity : undefined;
if (!identity) throw new RepositoryValidationError('Eine Baselinegruppe besitzt keine aufgeloeste technische Identitaet.');
if (analysis.analysis.status !== 'complete' || analysis.analysis.admxCatalog.status !== 'complete') {
diagnostics.add('policy-evaluation-baseline-incomplete');
}
if (state.status !== 'configured' || state.completeness !== 'complete' || state.targeting !== 'none') {
diagnostics.add(state.status === 'conflicted' ? 'policy-evaluation-customer-conflict'
: state.status === 'deleted' ? 'policy-evaluation-deleted-state-review'
: 'policy-evaluation-customer-state-incomplete');
}
const usedAnalyses: StoredMicrosoftSecurityBaselineAnalysis[] = [];
const usedSettings: MicrosoftSecurityBaselineSetting[] = [];
for (const context of applicableContexts) {
if (!context) continue;
const candidates = input.baselineAnalyses.filter(analysis => {
const record = recordsById.get(analysis.analysis.baselineId);
return Boolean(record && baselineMatchesContext(record, context, state.identity.scope));
});
if (candidates.length !== 1) {
diagnostics.add(candidates.length ? 'policy-evaluation-baseline-ambiguous' : 'policy-evaluation-baseline-missing');
continue;
}
const analysis = analysesById.get(candidates[0].analysis.id)!;
usedAnalyses.push(analysis);
if (analysis.analysis.status !== 'complete' || analysis.analysis.admxCatalog.status !== 'complete') {
diagnostics.add('policy-evaluation-baseline-incomplete');
baselineState = 'parser-gap';
}
if (rule.baselineState === 'explicit-recommendation') {
const settings = resolvedSettings(analysis, state.identity.id);
if (!settings.length) {
diagnostics.add('policy-evaluation-baseline-recommendation-missing');
baselineState = 'catalog-gap';
}
usedSettings.push(...settings);
}
}
const uniqueAnalyses = [...new Map(usedAnalyses.map(analysis => [analysis.analysis.id, analysis])).values()];
const uniqueSettings = [...new Map(usedSettings.map(setting => [setting.id, setting])).values()];
const recommendationSignatures = new Set(uniqueSettings.map(setting => canonicalJson(setting.observation.values[0])));
const recommendationSignatures = new Set(settings.map(setting => canonicalJson(setting.observation.values[0])));
if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-consensus-conflict');
const customerSignatures = new Set(state.values.map(value => canonicalJson(value)));
if (rule.baselineState === 'explicit-recommendation' && customerSignatures.size !== 1) {
diagnostics.add('policy-evaluation-customer-value-ambiguous');
}
const customerSignatures = new Set(state?.values.map(value => canonicalJson(value)) ?? []);
if (state && customerSignatures.size !== 1) diagnostics.add('policy-evaluation-customer-value-ambiguous');
const customerState: PolicyEvaluationResult['customerState'] = !state || state.status === 'deleted'
? 'missing'
: state.status === 'conflicted' ? 'conflicted'
: state.status === 'indeterminate' ? 'indeterminate' : 'configured';
if (customerState === 'conflicted') diagnostics.add('policy-evaluation-customer-conflict');
if (customerState === 'indeterminate') diagnostics.add('policy-evaluation-customer-state-incomplete');
let compared: PolicyComparatorOutcome = {
version: 1,
comparator: rule.comparator,
relation: 'incomparable',
comparator: { kind: 'equality' },
relation: customerState === 'missing' ? 'different' : 'incomparable',
baseline: undefined,
diagnosticCodes: []
};
if (!diagnostics.size && rule.baselineState === 'explicit-recommendation' && !rule.notApplicable) {
compared = comparePolicyValues(state.values[0], uniqueSettings[0]?.observation.values[0], rule.comparator);
if (compared.diagnosticCodes.length) baselineState = 'unsupported-setting';
if (recommendationSignatures.size === 1 && customerState === 'missing') {
compared = comparePolicyValues(undefined, settings[0].observation.values[0], { kind: 'equality' });
if (compared.baseline !== undefined) {
compared = { ...compared, relation: 'different', diagnosticCodes: [] };
} else {
compared.diagnosticCodes.forEach(code => diagnostics.add(code));
}
} else if (recommendationSignatures.size === 1 && customerState === 'configured' && customerSignatures.size === 1 && state) {
compared = comparePolicyValues(state.values[0], settings[0].observation.values[0], { kind: 'equality' });
compared.diagnosticCodes.forEach(code => diagnostics.add(code));
}
const review = diagnostics.size > 0
|| (!rule.notApplicable && rule.baselineState === 'explicit-recommendation' && compared.relation === 'incomparable');
const review = recommendationSignatures.size !== 1 || ['conflicted', 'indeterminate'].includes(customerState)
|| compared.relation === 'incomparable';
const classification: PolicyEvaluationResult['classification'] = review
? 'review_required'
: rule.notApplicable
? 'not_applicable'
: rule.baselineState !== 'explicit-recommendation'
? 'review_required'
: compared.relation === 'match' || compared.relation === 'stronger'
? 'configured_match'
: 'confirmed_configured_deviation';
const resultId = stableId('policy-evaluation-result', runId + '\0' + state.id);
const baselineAnalysisIds = sortedUnique(uniqueAnalyses.map(analysis => analysis.analysis.id));
const baselineSettingIds = sortedUnique(uniqueSettings.map(setting => setting.id));
: compared.relation === 'match' ? 'configured_match' : 'confirmed_configured_deviation';
const resultId = stableId('policy-evaluation-result', runId + '\0' + analysis.analysis.id + '\0' + identity.id);
const baselineAnalysisIds = [analysis.analysis.id];
const baselineSettingIds = sortedUnique(settings.map(setting => setting.id));
const result: PolicyEvaluationResult = {
version: 1,
id: resultId,
evaluationRunId: runId,
aggregationStateId: state.id,
identity: state.identity,
aggregationStateId: customerState === 'missing' ? undefined : state?.id,
baselineAnalysisId: analysis.analysis.id,
baselineId: baseline.id,
baselineLabel: baseline.fileName.replace(/\.zip$/i, ''),
customerState,
identity,
classification,
baselineState,
baselineState: compared.diagnosticCodes.length ? 'unsupported-setting' : 'explicit-recommendation',
comparator: compared,
ruleIds: rule.ruleIds,
dependencyRuleIds: rule.dependencyRuleIds,
riskRuleIds: rule.riskRuleIds,
contextIds: sortedUnique(state.applicableContextIds),
observationIds: sortedUnique(state.observationIds),
backupInstanceIds: sortedUnique(state.backupInstanceIds),
ruleIds: [],
dependencyRuleIds: [],
riskRuleIds: [],
contextIds: sortedUnique(state?.applicableContextIds ?? []),
observationIds: customerState === 'missing' ? [] : sortedUnique(state?.observationIds ?? []),
backupInstanceIds: customerState === 'missing' ? [] : sortedUnique(state?.backupInstanceIds ?? []),
baselineAnalysisIds,
baselineSettingIds,
findingEligibility: classification === 'confirmed_configured_deviation' && rule.riskRuleIds.length
? 'eligible'
: 'ineligible',
findingEligibility: 'ineligible',
effectiveState: 'unknown',
diagnosticCodes: [...diagnostics].sort()
};
@ -209,9 +157,23 @@ export function buildPolicyEvaluation(input: EvaluationBuildInput): StoredPolicy
canonicalJson(input.ruleCatalog),
...baselineAnalysisIds
].join('\0'));
const analysesById = new Map(input.baselineAnalyses.map(record => [record.analysis.id, record]));
const recordsById = new Map(input.baselineRecords.map(record => [record.id, record]));
const built = input.aggregation.states.map(state => evaluateState(runId, input, state, analysesById, recordsById));
const statesByIdentity = new Map(input.aggregation.states.map(state => [state.identity.id, state]));
const baselinesById = new Map(input.baselineRecords.map(record => [record.id, record]));
const built = input.baselineAnalyses.flatMap(analysis => {
const baseline = baselinesById.get(analysis.analysis.baselineId);
if (!baseline) throw new RepositoryValidationError('Eine Baselineanalyse verweist auf keinen Baselinekatalogeintrag.');
const grouped = new Map<string, MicrosoftSecurityBaselineSetting[]>();
for (const setting of analysis.analysis.settings) {
if (setting.identity.status !== 'resolved') continue;
// A structurally parsed baseline row without an actual Microsoft
// recommendation is not a benchmark and must not produce PASS/FAIL.
if (setting.observation.values[0] === undefined) continue;
const id = setting.identity.identity.id;
grouped.set(id, [...(grouped.get(id) ?? []), setting]);
}
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))
.map(([identityId, settings]) => evaluateBaselineIdentity(runId, analysis, baseline, settings, statesByIdentity.get(identityId)));
});
const results = built.map(item => item.result).sort((left, right) => left.id.localeCompare(right.id));
const evidence = built.flatMap(item => item.evidence).sort((left, right) => left.id.localeCompare(right.id));
const diagnostics = sortedUnique([
@ -219,7 +181,7 @@ export function buildPolicyEvaluation(input: EvaluationBuildInput): StoredPolicy
...results.flatMap(result => result.diagnosticCodes)
]);
const blocked = input.aggregation.run.status === 'blocked';
const partial = blocked || input.aggregation.run.status !== 'complete' || results.some(result => result.classification === 'review_required');
const partial = blocked || results.some(result => result.classification === 'review_required');
const record: StoredPolicyEvaluation = {
version: 1,
run: {
@ -316,4 +278,3 @@ export class GpoPolicyEvaluationService {
return structuredClone(this.ruleCatalog);
}
}

View File

@ -72,7 +72,7 @@ export function buildPolicyFindingRun(input: PolicyFindingBuildInput): StoredPol
for (const result of eligible) {
const codes = new Set(policyFindingCandidateInvariantCodes(input.evaluation, result));
const state = states.get(result.aggregationStateId);
const state = result.aggregationStateId ? states.get(result.aggregationStateId) : undefined;
if (!state || state.identity.id !== result.identity.id) codes.add('policy-finding-aggregation-state-missing');
else {
if (state.status !== 'configured') codes.add('policy-finding-customer-state-not-configured');
@ -105,7 +105,7 @@ export function buildPolicyFindingRun(input: PolicyFindingBuildInput): StoredPol
evaluationRunId: input.evaluation.run.id,
evaluationResultId: result.id,
aggregationRunId: input.aggregation.run.id,
aggregationStateId: result.aggregationStateId,
aggregationStateId: result.aggregationStateId!,
snapshotId: input.aggregation.run.snapshotId,
environmentProfileId: input.profile.id,
environmentProfileRevision: input.profile.revision,

View File

@ -79,6 +79,12 @@ function reduceBackupContribution(entries: ResolvedObservation[]): BackupContrib
}
const mechanism = observations[0].observation.deliveryMechanism;
if (mechanism === 'security-template' || mechanism === 'advanced-audit-policy') {
const values = valuesUnique(observations.flatMap(entry => entry.observation.values));
const complete = observations.every(entry => entry.observation.state === 'configured') && values.length === 1;
return { status: complete ? 'configured' : 'indeterminate', values, observationIds,
targeting: 'none', diagnosticCodes: [...diagnostics].sort() };
}
if (mechanism !== 'registry-policy') {
diagnostics.add('policy-aggregation-delivery-semantics-unsupported');
return {
@ -411,4 +417,3 @@ export class GpoPolicySnapshotService {
return this.repository.listPolicySnapshotAggregations(projectId);
}
}

View File

@ -209,7 +209,7 @@ export function reduceDriveMapsPreferences(
if (document.status === 'invalid' || document.scope !== 'user') return [];
return document.instructions.map(instruction => {
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_DRIVE_MAPS_REDUCER.version
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_DRIVE_MAPS_REDUCER.version
].join('\0')).digest('hex').slice(0, 32)}`;
const diagnostics = new Set<string>();
if (instruction.targeting.status === 'present') diagnostics.add('gpo-preference-targeting-unevaluated');

View File

@ -258,7 +258,7 @@ export function reduceLocalUsersGroupsPreferences(
? instruction.value.userName.toLocaleLowerCase('en-US')
: instruction.value.groupSid?.toLocaleUpperCase('en-US') ?? instruction.value.groupName.toLocaleLowerCase('en-US');
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_LOCAL_USERS_GROUPS_REDUCER.version
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_LOCAL_USERS_GROUPS_REDUCER.version
].join('\0')).digest('hex').slice(0, 32)}`;
const diagnostics = new Set<string>();
if (instruction.targeting.status === 'present') diagnostics.add('gpo-preference-targeting-unevaluated');

View File

@ -208,7 +208,7 @@ export function reduceServicesPreferences(
if (document.status === 'invalid' || document.scope !== 'computer') return [];
return document.instructions.map(instruction => {
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_SERVICES_REDUCER.version
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_SERVICES_REDUCER.version
].join('\0')).digest('hex').slice(0, 32)}`;
const diagnostics = new Set<string>();
if (instruction.targeting.status === 'present') diagnostics.add('gpo-preference-targeting-unevaluated');

View File

@ -320,7 +320,7 @@ export function reduceGroupPolicyPreferences(
};
const identityKey = [instruction.hive, instruction.key, instruction.target === 'key' ? '<key>' : instruction.valueName ?? ''].join('\\');
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_REGISTRY_REDUCER.version
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_REGISTRY_REDUCER.version
].join('\0')).digest('hex').slice(0, 32)}`;
const observation: GpoPolicyObservation = {
version: 1, id, backupInstanceId: context.backupInstanceId, sourceArtifactId: context.sourceArtifactId,

View File

@ -8,10 +8,13 @@ import {
} from '../../src/domain/microsoft-security-baseline-analysis';
import { aggregateGpoBackupMetadata } from '../../src/domain/gpo-metadata';
import { classifyGpoArtifactPath } from '../../src/gpo/artifact-classifier';
import { buildAdmxPolicyCatalog, resolveRegistryPolicyIdentity } from './admx-policy-catalog';
import { buildAdmxPolicyCatalog } from './admx-policy-catalog';
import { parseGpoBackupMetadataXml } from './gpo-metadata-parser';
import { readMicrosoftBaselineZip } from './microsoft-baseline-zip-reader';
import { parseRegistryPolicyFile, reduceRegistryPolicy } from './registry-policy-parser';
import { parseSecurityTemplateInf, reduceSecurityTemplate } from './security-template-parser';
import { parseAdvancedAuditCsv, reduceAdvancedAuditPolicy } from './advanced-audit-parser';
import { resolvePolicyTarget } from './policy-target-resolver';
export interface MicrosoftSecurityBaselineAnalysisOptions {
baselineId: string;
@ -37,7 +40,8 @@ function stableId(prefix: string, parts: string[]): string {
function selectedBaselineEntry(path: string): boolean {
return /(?:^|\/)Backup\.xml$/i.test(path)
|| /(?:^|\/)Registry\.pol$/i.test(path)
|| /\.(?:admx|adml)$/i.test(path);
|| /\.(?:admx|adml)$/i.test(path)
|| ['security-template', 'advanced-audit-policy'].includes(classifyGpoArtifactPath(path).role);
}
export function analyzeMicrosoftSecurityBaselinePackage(
@ -61,10 +65,10 @@ export function analyzeMicrosoftSecurityBaselinePackage(
const metadataFiles = archive.files.filter(file => /(?:^|\/)Backup\.xml$/i.test(file.entry.relativePath));
const roots = metadataFiles.map(file => parentPath(file.entry.relativePath))
.filter(root => archive.entries.some(entry => entry.kind === 'file' && belongsToRoot(entry.relativePath, root)
&& classifyGpoArtifactPath(entry.relativePath).role === 'registry-policy'))
&& ['registry-policy', 'security-template', 'advanced-audit-policy'].includes(classifyGpoArtifactPath(entry.relativePath).role)))
.sort((left, right) => right.length - left.length || left.localeCompare(right));
if (!roots.length) diagnostics.add('microsoft-baseline-registry-policy-missing');
const selectedRegistryFiles = archive.files.filter(file => classifyGpoArtifactPath(file.entry.relativePath).role === 'registry-policy'
const selectedRegistryFiles = archive.files.filter(file => ['registry-policy', 'security-template', 'advanced-audit-policy'].includes(classifyGpoArtifactPath(file.entry.relativePath).role)
&& roots.some(root => belongsToRoot(file.entry.relativePath, root)));
const metadataByRoot = new Map<string, ReturnType<typeof parseGpoBackupMetadataXml>[]>();
for (const file of metadataFiles) {
@ -93,20 +97,27 @@ export function analyzeMicrosoftSecurityBaselinePackage(
const registryFiles = selectedRegistryFiles.filter(file => belongsToRoot(file.entry.relativePath, root));
for (const file of registryFiles) {
const classification = classifyGpoArtifactPath(file.entry.relativePath);
const document = parseRegistryPolicyFile(file, classification.scope, options.resourcePolicy);
const context = { backupInstanceId: backupId, sourceArtifactId: options.baselineId,
artifactId: stableId('baseline-entry', [archive.containerSha256, file.entry.relativePath, file.entry.sha256]) };
const parsed = classification.role === 'security-template' ? (() => {
const document = parseSecurityTemplateInf(file, classification.scope, options.resourcePolicy);
return { document, observations: reduceSecurityTemplate(document, context) };
})() : classification.role === 'advanced-audit-policy' ? (() => {
const document = parseAdvancedAuditCsv(file, classification.scope, options.resourcePolicy);
return { document, observations: reduceAdvancedAuditPolicy(document, context) };
})() : (() => {
const document = parseRegistryPolicyFile(file, classification.scope, options.resourcePolicy);
return { document, observations: reduceRegistryPolicy(document, context) };
})();
const { document, observations } = parsed;
document.diagnosticCodes.forEach(code => diagnostics.add(code));
if (document.status !== 'complete') partial = true;
const observations = reduceRegistryPolicy(document, {
backupInstanceId: backupId,
sourceArtifactId: options.baselineId,
artifactId: stableId('baseline-entry', [archive.containerSha256, file.entry.relativePath, file.entry.sha256])
});
for (const observation of observations) {
const identity = resolveRegistryPolicyIdentity(observation, admxCatalog);
const identity = resolvePolicyTarget(observation, admxCatalog);
if (identity.status !== 'resolved') {
diagnostics.add('microsoft-baseline-policy-identity-unresolved');
partial = true;
} else if (identity.identity.admx.status !== 'matched') {
} else if (observation.deliveryMechanism === 'registry-policy' && identity.identity.admx.status !== 'matched') {
diagnostics.add(identity.identity.admx.status === 'ambiguous'
? 'microsoft-baseline-admx-identity-ambiguous'
: 'microsoft-baseline-admx-metadata-not-found');
@ -116,7 +127,7 @@ export function analyzeMicrosoftSecurityBaselinePackage(
version: 1,
id: stableId('microsoft-baseline-setting', [
options.baselineId, backupId, observation.sourceSha256,
observation.registryPolicy?.sequence.toString() ?? '', MICROSOFT_BASELINE_SETTINGS_ANALYZER.version
observation.id, MICROSOFT_BASELINE_SETTINGS_ANALYZER.version
]),
baselineId: options.baselineId,
backupId,
@ -131,6 +142,7 @@ export function analyzeMicrosoftSecurityBaselinePackage(
values: observation.values,
registryPolicy: observation.registryPolicy,
sourceRecords: observation.sourceRecords,
sourceLines: observation.sourceLines,
effectiveState: observation.effectiveState,
diagnosticCodes: observation.diagnosticCodes
},
@ -150,7 +162,7 @@ export function analyzeMicrosoftSecurityBaselinePackage(
status: partial ? 'partial' : 'complete',
admxCatalog,
backupCount: new Set(roots).size,
registryPolicyFileCount: selectedRegistryFiles.length,
registryPolicyFileCount: selectedRegistryFiles.filter(file => classifyGpoArtifactPath(file.entry.relativePath).role === 'registry-policy').length,
settings: settings.sort((left, right) => left.id.localeCompare(right.id)),
diagnosticCodes: [...diagnostics].sort()
};

View File

@ -0,0 +1,24 @@
import { createHash } from 'node:crypto';
import { GPO_NATIVE_POLICY_IDENTITY_RESOLVER, gpoTechnicalPolicyIdentityValidationErrors,
type AdmxPolicyCatalog, type GpoPolicyIdentityResolution } from '../../src/domain/gpo-policy-identity';
import type { GpoPolicyObservation } from '../../src/domain/gpo-policy';
import { resolveRegistryPolicyIdentity } from './admx-policy-catalog';
export function resolvePolicyTarget(observation: GpoPolicyObservation, catalog?: AdmxPolicyCatalog): GpoPolicyIdentityResolution {
if (observation.deliveryMechanism === 'registry-policy') return resolveRegistryPolicyIdentity(observation, catalog);
if (!['security-template', 'advanced-audit-policy'].includes(observation.deliveryMechanism)) {
return { version: 1, status: 'unresolved', reasonCode: `identity-resolver-unsupported-${observation.deliveryMechanism}` };
}
const kind = observation.deliveryMechanism as 'security-template' | 'advanced-audit-policy';
const section = observation.identity.section.normalize('NFC').toLowerCase();
const key = observation.identity.key.normalize('NFC').toLowerCase();
const digest = createHash('sha256').update(['native-policy-target-v1', kind, observation.scope, section, key].join('\0')).digest('hex');
const identity = {
version: 1 as const, id: `gpo-policy-identity-${digest.slice(0, 32)}`, resolver: GPO_NATIVE_POLICY_IDENTITY_RESOLVER,
scope: observation.scope, target: { kind, section, key }, admx: { status: 'not-found' as const }
};
if (gpoTechnicalPolicyIdentityValidationErrors(identity).length) {
return { version: 1, status: 'unresolved', reasonCode: 'policy-technical-target-invalid' };
}
return { version: 1, status: 'resolved', identity };
}

View File

@ -324,6 +324,7 @@ export function reduceRegistryPolicy(
const target = interpreted.value.valueName ?? interpreted.value.targets?.join(';') ?? interpreted.value.operation;
const id = 'gpo-policy-' + createHash('sha256').update([
context.backupInstanceId,
context.artifactId,
document.sourceSha256,
instruction.sequence.toString(),
REGISTRY_POLICY_REDUCER.version

View File

@ -158,6 +158,7 @@ export function reduceSecurityTemplate(
const conflicted = distinctValues.length > 1;
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId,
context.artifactId,
document.sourceSha256,
first.section,
first.key.toLocaleLowerCase('en-US'),

View File

@ -10,6 +10,7 @@ import { GpoBackupJobProcessor, GpoBackupJobRunner } from './gpo/gpo-backup-job-
import { GpoArtifactReconciler, GpoArtifactReconciliationRunner } from './gpo/gpo-artifact-reconciler';
import { GpoBackupManagementService } from './gpo/gpo-backup-management-service';
import { NodeGpoInventoryWorkerExecutor } from './gpo/gpo-inventory-worker-client';
import { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
import { MicrosoftSecurityBaselineService } from './gpo/microsoft-security-baseline-service';
import { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
@ -73,11 +74,14 @@ async function main(): Promise<void> {
});
const gpoPolicyFindingService = new GpoPolicyFindingService(runtime.repository);
const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository);
const automaticGpoAnalysis = new GpoAutomaticAnalysisService(runtime.repository,
gpoPolicySnapshotService, gpoPolicyEvaluationService, gpoPolicyFindingService);
await microsoftSecurityBaselineService.refreshDetectedMetadata();
const baselineBackfill = await microsoftSecurityBaselineService.reprocessPending();
if (baselineBackfill.failed) console.warn('Microsoft-Baseline-Backfill:', baselineBackfill.analyzed,
'analysiert,', baselineBackfill.failed, 'fehlgeschlagen.');
const gpoJobProcessor = new GpoBackupJobProcessor(runtime.repository, artifactStore, gpoInventoryExecutor, {
afterAnalysis: (projectId, runId) => automaticGpoAnalysis.run(projectId, runId),
maximumArtifactBytes: defaultImportResourcePolicy.maximumFileBytes,
leaseDurationMs: boundedInteger(process.env.ASSESSOR_GPO_JOB_LEASE_MS, 60_000, 1_000, 3_600_000, 'ASSESSOR_GPO_JOB_LEASE_MS'),
retryDelayMs: boundedInteger(process.env.ASSESSOR_GPO_JOB_RETRY_MS, 5_000, 0, 300_000, 'ASSESSOR_GPO_JOB_RETRY_MS')
@ -108,6 +112,7 @@ async function main(): Promise<void> {
gpoPolicyEvaluationService,
gpoPolicyFindingService,
gpoPolicyAnalysisOverviewService,
automaticGpoAnalysis,
importExecutor
});
server.listen(parsedPort, host, () => {

View File

@ -16,6 +16,8 @@ import policyEvaluations from './migrations/015_policy_evaluations.sql?raw';
import policyEvaluationRuleCatalogs from './migrations/016_policy_evaluation_rule_catalogs.sql?raw';
import policyFindingFactory from './migrations/017_policy_finding_factory.sql?raw';
import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw';
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
export interface DatabaseMigration {
version: string;
@ -41,5 +43,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '015', name: 'policy-evaluations', sql: policyEvaluations },
{ version: '016', name: 'policy-evaluation-rule-catalogs', sql: policyEvaluationRuleCatalogs },
{ version: '017', name: 'policy-finding-factory', sql: policyFindingFactory },
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation }
];

View File

@ -0,0 +1,6 @@
-- Additive targets; existing Registry identities and original evidence remain unchanged.
ALTER TABLE assessor_policy_identities
DROP CONSTRAINT assessor_policy_identities_target_kind_check;
ALTER TABLE assessor_policy_identities
ADD CONSTRAINT assessor_policy_identities_target_kind_check
CHECK (target_kind IN ('registry-key', 'registry-value', 'security-template', 'advanced-audit-policy'));

View File

@ -0,0 +1,30 @@
ALTER TABLE assessor_policy_evaluation_results
ALTER COLUMN aggregation_state_id DROP NOT NULL,
ADD COLUMN baseline_analysis_id text REFERENCES assessor_microsoft_security_baseline_analyses(id) ON DELETE RESTRICT,
ADD COLUMN customer_state text NOT NULL DEFAULT 'configured'
CONSTRAINT assessor_policy_evaluation_results_customer_state_value_check
CHECK (customer_state IN ('configured', 'missing', 'conflicted', 'indeterminate')),
ADD CONSTRAINT assessor_policy_evaluation_results_customer_state_check CHECK (
(customer_state = 'missing' AND aggregation_state_id IS NULL)
OR (customer_state <> 'missing' AND aggregation_state_id IS NOT NULL)
);
DO $$
DECLARE constraint_name text;
BEGIN
SELECT con.conname INTO constraint_name
FROM pg_constraint con
WHERE con.conrelid = 'assessor_policy_evaluation_results'::regclass
AND con.contype = 'u'
AND pg_get_constraintdef(con.oid) = 'UNIQUE (evaluation_run_id, aggregation_state_id)';
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE assessor_policy_evaluation_results DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
CREATE UNIQUE INDEX assessor_policy_evaluation_results_baseline_identity_uq
ON assessor_policy_evaluation_results(evaluation_run_id, baseline_analysis_id, identity_id)
WHERE baseline_analysis_id IS NOT NULL;
CREATE INDEX assessor_policy_evaluation_results_baseline_idx
ON assessor_policy_evaluation_results(baseline_analysis_id, classification, id);

View File

@ -19,8 +19,8 @@ import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis } from '../../src
import type { MicrosoftSecurityBaselineClassification, MicrosoftSecurityBaselineRecord } from '../../src/domain/microsoft-security-baseline';
import type { StoredMicrosoftSecurityBaselineAnalysis } from '../../src/domain/microsoft-security-baseline-analysis';
import type { EnvironmentProfile, StoredPolicySnapshotAggregation } from '../../src/domain/gpo-snapshot-aggregation';
import type { StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import { storedPolicyEvaluationValidationErrors } from '../../src/domain/policy-evaluation';
import type { PolicyEvaluationResult, PolicyEvaluationRun, StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import { POLICY_EVALUATION_ENGINE, storedPolicyEvaluationValidationErrors } from '../../src/domain/policy-evaluation';
import type { StoredPolicyFindingRun } from '../../src/domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context';
import { environmentCoverage } from '../../src/domain/environment-context';
@ -34,6 +34,7 @@ import type { DbStructureColumn, DbStructureForeignKey, DbStructureLayerId, DbSt
import { storedGpoBackupInstanceValidationErrors, storedGpoPolicyAnalysisValidationErrors } from '../../src/domain/gpo-analysis-persistence';
import {
createEnvironmentUnifiedResult,
createPolicyEvaluationUnifiedResult,
createCurrentPolicyObservationUnifiedResults,
createPolicyUnifiedResult,
createScannerUnifiedResult,
@ -989,10 +990,13 @@ export class PostgresAssessorRepository implements AssessorRepository {
WHERE ${conditions.join(' AND ')}
ORDER BY o.scan_run_id, o.property_key, o.id
`, values);
return result.rows.map(row => createEnvironmentUnifiedResult({
project: clone(row.project), assessment: clone(row.assessment), scanRun: clone(row.scan_run),
importFile: clone(row.import_file), observation: clone(row.observation)
}));
return result.rows.flatMap(row => {
const projection = createEnvironmentUnifiedResult({
project: clone(row.project), assessment: clone(row.assessment), scanRun: clone(row.scan_run),
importFile: clone(row.import_file), observation: clone(row.observation)
});
return projection ? [projection] : [];
});
}
private async unifiedPolicyResults(
@ -1013,17 +1017,90 @@ export class PostgresAssessorRepository implements AssessorRepository {
const result = await this.pool.query<{
definition: FindingDefinition;
occurrence: StoredPolicyFindingRun['occurrences'][number];
comparison: PolicyEvaluationResult | null;
} & QueryResultRow>(`
SELECT d.data AS definition, o.data AS occurrence
SELECT d.data AS definition, o.data AS occurrence, comparison.data AS comparison
FROM assessor_policy_finding_occurrences o
JOIN assessor_policy_finding_runs r ON r.id = o.factory_run_id
JOIN assessor_finding_definitions d ON d.id = o.definition_id
LEFT JOIN assessor_policy_evaluation_results comparison ON comparison.id = o.evaluation_result_id
AND comparison.evaluation_run_id = r.evaluation_run_id
${where}
ORDER BY r.completed_at, o.id
`, values);
return result.rows.map(row => createPolicyUnifiedResult({
definition: clone(row.definition),
occurrence: clone(row.occurrence)
occurrence: clone(row.occurrence),
comparison: row.comparison ? clone(row.comparison) : undefined
}));
}
private async unifiedPolicyEvaluationComparisonResults(
query: UnifiedResultQuery = {},
evaluationResultId?: string
): Promise<UnifiedResultProjection[]> {
if (query.sourceKind?.length && !query.sourceKind.includes('policy-evaluation')) return [];
const values: unknown[] = [POLICY_EVALUATION_ENGINE.version];
const conditions = ['finding.id IS NULL'];
const add = (expression: string, value: unknown) => {
values.push(value);
conditions.push(expression.replace('?', `$${values.length}`));
};
if (query.projectId) add('selected.project_id = ?', query.projectId);
if (query.evaluationRunId) add('selected.id = ?', query.evaluationRunId);
if (evaluationResultId) add('result.id = ?', evaluationResultId);
const rows = await this.pool.query<{
run: PolicyEvaluationRun;
result: PolicyEvaluationResult;
snapshot_id: string;
assessment_ids: string[];
artifact_ids: string[];
} & QueryResultRow>(`
WITH ranked AS (
SELECT evaluation.*,
row_number() OVER (
PARTITION BY evaluation.aggregation_run_id
ORDER BY jsonb_array_length(evaluation.data->'run'->'baselineAnalysisIds') DESC,
evaluation.completed_at DESC, evaluation.id DESC
) AS run_rank
FROM assessor_policy_evaluation_runs evaluation
WHERE evaluation.evaluator_version = $1
AND NOT EXISTS (
SELECT 1
FROM assessor_policy_snapshot_aggregation_sources source
JOIN assessor_policy_identity_analysis_runs newer
ON newer.supersedes_analysis_run_id = source.analysis_run_id
WHERE source.aggregation_run_id = evaluation.aggregation_run_id
)
), selected AS MATERIALIZED (
SELECT id, project_id, aggregation_run_id, completed_at, data->'run' AS run
FROM ranked
WHERE run_rank = 1
), aggregation_context AS MATERIALIZED (
SELECT source.aggregation_run_id,
array_agg(DISTINCT artifact.assessment_id) FILTER (WHERE artifact.assessment_id IS NOT NULL) AS assessment_ids,
array_agg(DISTINCT artifact.id) AS artifact_ids
FROM assessor_policy_snapshot_aggregation_sources source
JOIN assessor_policy_identity_analysis_runs analysis ON analysis.id = source.analysis_run_id
JOIN assessor_import_artifacts artifact ON artifact.id = analysis.artifact_id
GROUP BY source.aggregation_run_id
)
SELECT selected.run, result.data AS result,
aggregation.snapshot_id,
coalesce(context.assessment_ids, ARRAY[]::text[]) AS assessment_ids,
coalesce(context.artifact_ids, ARRAY[]::text[]) AS artifact_ids
FROM selected
JOIN assessor_policy_evaluation_results result ON result.evaluation_run_id = selected.id
JOIN assessor_policy_snapshot_aggregation_runs aggregation ON aggregation.id = selected.aggregation_run_id
LEFT JOIN aggregation_context context ON context.aggregation_run_id = selected.aggregation_run_id
LEFT JOIN assessor_policy_finding_occurrences finding ON finding.evaluation_result_id = result.id
WHERE ${conditions.join(' AND ')}
ORDER BY selected.completed_at, result.id
`, values);
return rows.rows.map(row => createPolicyEvaluationUnifiedResult(row.run, row.result, {
assessmentId: row.assessment_ids.length === 1 ? row.assessment_ids[0] : undefined,
importArtifactId: row.artifact_ids.length === 1 ? row.artifact_ids[0] : undefined,
snapshotId: row.snapshot_id
}));
}
@ -1031,6 +1108,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
query: UnifiedResultQuery = {},
observationId?: string
): Promise<UnifiedResultProjection[]> {
if (query.audience !== 'internal-analysis') return [];
if (query.sourceKind?.length && !query.sourceKind.includes('policy-observation')) return [];
const where = query.projectId ? 'WHERE project_id = $1' : '';
const values = query.projectId ? [query.projectId] : [];
@ -1050,13 +1128,14 @@ export class PostgresAssessorRepository implements AssessorRepository {
}
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
const [scanner, environment, policyObservations, policy] = await Promise.all([
const [scanner, environment, policyObservations, policyEvaluations, policy] = await Promise.all([
this.unifiedScannerResults(query),
this.unifiedEnvironmentResults(query),
this.unifiedPolicyObservationResults(query),
this.unifiedPolicyEvaluationComparisonResults(query),
this.unifiedPolicyResults(query)
]);
return [...scanner, ...environment, ...policyObservations, ...policy];
return [...scanner, ...environment, ...policyObservations, ...policyEvaluations, ...policy];
}
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
@ -1070,10 +1149,13 @@ export class PostgresAssessorRepository implements AssessorRepository {
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
const scannerPrefix = 'scanner-finding:';
const policyPrefix = 'policy-evaluation:';
const policyComparisonPrefix = 'policy-evaluation-comparison:';
const policyObservationPrefix = 'policy-observation:';
const environmentPrefix = 'environment-observation:';
const items = resultProjectionId.startsWith(scannerPrefix)
? await this.unifiedScannerResults({}, resultProjectionId.slice(scannerPrefix.length))
: resultProjectionId.startsWith(policyComparisonPrefix)
? await this.unifiedPolicyEvaluationComparisonResults({}, resultProjectionId.slice(policyComparisonPrefix.length))
: resultProjectionId.startsWith(policyPrefix)
? await this.unifiedPolicyResults({}, resultProjectionId.slice(policyPrefix.length))
: resultProjectionId.startsWith(policyObservationPrefix)
@ -1569,7 +1651,9 @@ export class PostgresAssessorRepository implements AssessorRepository {
[record.run.aggregationRunId]
);
const stateIdentity = new Map(states.rows.map(row => [row.id, row.identity_id]));
if (record.results.some(result => stateIdentity.get(result.aggregationStateId) !== result.identity.id)) {
if (record.results.some(result => result.aggregationStateId
? stateIdentity.get(result.aggregationStateId) !== result.identity.id
: result.customerState !== 'missing')) {
throw new RepositoryValidationError('Ein Evaluationsergebnis verweist auf einen fremden Aggregationszustand.');
}
await client.query(
@ -1589,8 +1673,9 @@ export class PostgresAssessorRepository implements AssessorRepository {
}
for (const result of record.results) {
await client.query(
'INSERT INTO assessor_policy_evaluation_results (id, evaluation_run_id, aggregation_state_id, identity_id, classification, baseline_state, comparator_kind, rule_ids, dependency_rule_ids, risk_rule_ids, finding_eligible, effective_state, data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::text[], $9::text[], $10::text[], $11, $12, $13::jsonb)',
[result.id, record.run.id, result.aggregationStateId, result.identity.id, result.classification,
'INSERT INTO assessor_policy_evaluation_results (id, evaluation_run_id, aggregation_state_id, baseline_analysis_id, customer_state, identity_id, classification, baseline_state, comparator_kind, rule_ids, dependency_rule_ids, risk_rule_ids, finding_eligible, effective_state, data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::text[], $11::text[], $12::text[], $13, $14, $15::jsonb)',
[result.id, record.run.id, result.aggregationStateId ?? null, result.baselineAnalysisId, result.customerState,
result.identity.id, result.classification,
result.baselineState, result.comparator.comparator.kind, result.ruleIds, result.dependencyRuleIds,
result.riskRuleIds, result.findingEligibility === 'eligible', result.effectiveState, json(result)]
);
@ -1707,15 +1792,16 @@ export class PostgresAssessorRepository implements AssessorRepository {
'SELECT data FROM assessor_microsoft_security_baselines WHERE id = $1 FOR UPDATE', [baselineId]
));
if (!record) return null;
const referenced = await client.query(`
SELECT 1
FROM assessor_policy_evaluation_baseline_sources
WHERE baseline_id = $1
LIMIT 1
// Evaluations and findings are reproducible derivatives. Explicit baseline deletion
// removes only chains that consume this baseline; customer snapshots and imports remain.
await client.query(`
DELETE FROM assessor_policy_evaluation_runs
WHERE id IN (
SELECT evaluation_run_id
FROM assessor_policy_evaluation_baseline_sources
WHERE baseline_id = $1
)
`, [baselineId]);
if (referenced.rowCount) {
throw new RepositoryConflictError('The Microsoft baseline is referenced by a policy evaluation and cannot be deleted.');
}
await client.query('UPDATE assessor_microsoft_security_baseline_classifications SET supersedes_classification_id = NULL WHERE baseline_id = $1', [baselineId]);
await client.query('UPDATE assessor_microsoft_security_baseline_analyses SET supersedes_analysis_id = NULL WHERE baseline_id = $1', [baselineId]);
await client.query('DELETE FROM assessor_microsoft_security_baselines WHERE id = $1', [baselineId]);
@ -1852,17 +1938,19 @@ export class PostgresAssessorRepository implements AssessorRepository {
'status', 'dead-letter', 'lastErrorCode', coalesce(data->>'lastErrorCode', 'lease-expired'), 'updatedAt', $1::text
)
WHERE job_status = 'running' AND attempts >= max_attempts AND lease_expires_at <= $1
`, [claim.now]);
AND ($2::text IS NULL OR project_id = $2)
`, [claim.now, claim.projectId ?? null]);
const selected = await client.query<DataRow<IngestionJob>>(`
SELECT data FROM assessor_ingestion_jobs
WHERE attempts < max_attempts
AND ($2::text[] IS NULL OR job_kind = ANY($2::text[]))
AND ($3::text IS NULL OR project_id = $3)
AND ((job_status = 'queued' AND available_at <= $1)
OR (job_status = 'running' AND lease_expires_at <= $1))
ORDER BY available_at, created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1
`, [claim.now, claim.kinds?.length ? claim.kinds : null]);
`, [claim.now, claim.kinds?.length ? claim.kinds : null, claim.projectId ?? null]);
const current = selected.rows[0]?.data;
if (!current) return { job: null, cleaned: Boolean(exhausted.rowCount) };
const updated: IngestionJob = {
@ -1946,19 +2034,19 @@ export class PostgresAssessorRepository implements AssessorRepository {
});
}
async deleteFailedGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest) {
async deleteGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest) {
return this.mutate(async client => {
const selected = await client.query<DataRow<ImportArtifact>>(
'SELECT data FROM assessor_import_artifacts WHERE id = $1 AND project_id = $2 FOR UPDATE', [artifactId, projectId]
);
const artifact = selected.rows[0]?.data;
if (!artifact || artifact.storage.kind !== 'backend-controlled' || artifact.kind !== 'source-file' || !artifact.relativePath.startsWith('gpo-backups/')) {
throw new RepositoryNotFoundError('Das fehlgeschlagene GPO-Artefakt ist im Mandanten nicht vorhanden.');
throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.');
}
const jobs = dataRows(await client.query<DataRow<IngestionJob>>(
`SELECT data FROM assessor_ingestion_jobs WHERE artifact_id = $1 AND project_id = $2 AND job_kind = 'analyze-gpo-backup' FOR UPDATE`, [artifactId, projectId]
));
if (!jobs.length || jobs.some(item => item.status !== 'dead-letter')) throw new RepositoryConflictError('Nur ein endgültig fehlgeschlagener GPO-Import kann gelöscht werden.');
if (jobs.some(item => item.status === 'queued' || item.status === 'running')) throw new RepositoryConflictError('Ein laufender GPO-Import kann nicht gelöscht werden.');
if (request.version !== 1 || request.projectId !== projectId || request.artifactId !== artifactId
|| request.storageKey !== artifact.storage.storageKey || request.attempts !== 0
|| !request.id.trim() || !Number.isFinite(Date.parse(request.createdAt)) || !Number.isFinite(Date.parse(request.nextAttemptAt))) {
@ -1975,7 +2063,31 @@ export class PostgresAssessorRepository implements AssessorRepository {
'SELECT data FROM assessor_artifact_deletion_outbox WHERE storage_key = $1 FOR UPDATE', [request.storageKey]
)).rows[0]?.data;
if (!storedRequest || storedRequest.artifactId !== artifactId || storedRequest.projectId !== projectId) throw new RepositoryConflictError('Speicherobjekt besitzt einen abweichenden Löschauftrag.');
await client.query(`
DELETE FROM assessor_policy_snapshot_aggregation_runs aggregation
WHERE EXISTS (
SELECT 1
FROM assessor_policy_snapshot_aggregation_sources source
JOIN assessor_policy_identity_analysis_runs analysis ON analysis.id = source.analysis_run_id
WHERE source.aggregation_run_id = aggregation.id
AND analysis.project_id = $1
AND analysis.artifact_id = $2
)
`, [projectId, artifactId]);
await client.query(
'UPDATE assessor_policy_identity_analysis_runs SET supersedes_analysis_run_id = NULL WHERE project_id = $1 AND artifact_id = $2',
[projectId, artifactId]
);
await client.query(
'UPDATE assessor_import_processing_runs SET supersedes_run_id = NULL WHERE project_id = $1 AND artifact_id = $2',
[projectId, artifactId]
);
await client.query('DELETE FROM assessor_import_artifacts WHERE id = $1 AND project_id = $2', [artifactId, projectId]);
await client.query(`
DELETE FROM assessor_finding_definitions definition
WHERE NOT EXISTS (SELECT 1 FROM assessor_finding_occurrences occurrence WHERE occurrence.definition_id = definition.id)
AND NOT EXISTS (SELECT 1 FROM assessor_policy_finding_occurrences occurrence WHERE occurrence.definition_id = definition.id)
`);
return { artifact: clone(artifact), request: clone(storedRequest) };
});
}

View File

@ -1,4 +1,4 @@
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, ImportFileRecord } from '../domain/canonical-model';
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord } from '../domain/canonical-model';
import type {
UnifiedResultCategory,
UnifiedResultFacetResult,
@ -167,6 +167,14 @@ export class AssessorApiClient {
return this.request(`/assessments/${encodeURIComponent(assessmentId)}/model`);
}
listProjectReviews(projectId: string): Promise<FindingReview[]> {
return this.request(`/projects/${encodeURIComponent(projectId)}/reviews`);
}
saveReview(review: FindingReview, expectedRevision?: number): Promise<{ review: FindingReview; repositoryRevision: number }> {
return this.request('/reviews', { method: 'POST', body: JSON.stringify({ review, expectedRevision }) });
}
queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
return this.request(unifiedQueryPath('/results', query));
}
@ -209,6 +217,12 @@ export class AssessorApiClient {
});
}
evaluateGpoBackup(projectId: string, artifactId: string): Promise<{ state: 'context-required' | 'baseline-required' | 'review' | 'evaluated' }> {
return this.request(`/projects/${encodeURIComponent(projectId)}/gpo-backups/${encodeURIComponent(artifactId)}/evaluate`, {
method: 'POST', body: JSON.stringify({})
});
}
async submitGpoBackup(projectId: string, file: File, assessmentId?: string): Promise<GpoBackupSubmissionResponse> {
const bytes = new Uint8Array(await file.arrayBuffer());
return this.request(`/projects/${encodeURIComponent(projectId)}/gpo-backups`, {
@ -290,7 +304,7 @@ export class AssessorApiClient {
return this.request('/projects/' + encodeURIComponent(projectId) + '/policy-analysis-overview');
}
deleteFailedGpoBackup(projectId: string, artifactId: string): Promise<GpoBackupDeleteResponse> {
deleteGpoBackup(projectId: string, artifactId: string): Promise<GpoBackupDeleteResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/gpo-backups/${encodeURIComponent(artifactId)}`, {
method: 'DELETE', body: JSON.stringify({ artifactId, confirmDelete: true })
});

View File

@ -20,6 +20,7 @@ export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
export interface AssessorHealthResponse {
status: 'ok';
application: 'ASSESSOR';
productVersion: string;
apiVersion: 1;
releaseId?: string;
developmentInstanceId?: string;

View File

@ -16,14 +16,15 @@ import { rowsToSpreadsheetSafeCsv } from '../exports/csv-export';
import { downloadText } from '../exports/text-download';
import { exportSnapshot } from '../exports/snapshot-export';
import { browserDownloadAdapter } from '../platform/browser/browser-download-adapter';
import assessorDarkLogo from '../assets/branding/assessor-icon-dark-128.png';
import assessorLightLogo from '../assets/branding/assessor-icon-light-128.png';
import assessorLogo from '../assets/branding/assessor-icon-transparent.png';
import { TriangleIcon } from '../components/TriangleIcon';
import { SidebarSeverityMetrics } from '../features/sidebar/SidebarSeverityMetrics';
import { ViewBookmarks } from '../features/view-bookmarks/ViewBookmarks';
import { pathSnapshotFindingIds, type PathSnapshotState, type ViewBookmark } from '../features/view-bookmarks/view-bookmarks';
import { PathfinderFilterTrail } from '../features/mitre-graph/PathfinderFilterTrail';
import { CURRENT_ASSESSOR_PROCESSING_VERSION } from '../domain/processing-compatibility';
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { hasSelectedTimeslotScope } from './work-scope';
import { AboutDialog } from '../features/about/AboutDialog';
type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results';
@ -65,13 +66,15 @@ export function App() {
const [dataHubClientView, setDataHubClientView] = useState(false);
const [dataHubDetailClientName, setDataHubDetailClientName] = useState<string>();
const [pathSnapshotRequest, setPathSnapshotRequest] = useState<{ id: string; path: PathSnapshotState }>();
const [aboutOpen, setAboutOpen] = useState(false);
const { dataset, activeSheet, reviews, snapshot, theme, setTheme, delimiter, setDelimiter, view, setView, actionOnly, sidebarCollapsed, toggleSidebar, toggleWorkspace, backendStatus, targetProjectId } = app;
const marked = Object.values(reviews).filter(review => review.include).length;
const visible = useFilteredRecords().length;
const backendLabel = backendStatus === 'connected' ? 'Server connection available' : backendStatus === 'checking' ? 'Checking server connection' : 'No server connection';
const hasClientTimeslotScope = !targetProjectId || Boolean(app.activeScanRunIds?.length);
const hasClientTimeslotScope = !targetProjectId
|| hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds);
const headerClientName = (activeArea === 'data-hub' && dataHubClientView ? dataHubDetailClientName : activeProjectName)?.trim();
const handleDataHubDetailViewChanged = useCallback((isDetailView: boolean, clientName?: string) => {
setDataHubClientView(isDetailView);
@ -101,7 +104,7 @@ export function App() {
return <div className={`app-shell app-shell-${activeArea}${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
<header className="topbar">
<div className="brand" aria-label="ASSESSOR"><span className="brand-logo" aria-hidden="true"><img className="brand-logo-dark" src={assessorDarkLogo} alt=""/><img className="brand-logo-light" src={assessorLightLogo} alt=""/></span><span className="brand-copy"><h1>ASSESSOR</h1><span className="brand-version">{CURRENT_ASSESSOR_PROCESSING_VERSION}</span></span></div>
<button className="brand" type="button" aria-label="About ASSESSOR" title="About ASSESSOR" onClick={() => setAboutOpen(true)}><span className="brand-logo" aria-hidden="true"><img src={assessorLogo} alt=""/></span><span className="brand-copy"><h1>ASSESSOR</h1><span className="brand-version">{ASSESSOR_PRODUCT_VERSION}</span></span></button>
<div className={`app-header-panel app-header-panel-${activeArea}`} id={activeArea === 'graph' ? WORKSPACE_BAND_ID : undefined}>
{activeArea === 'work' ? <>
<div className="work-header-row">{readerToolbar}{headerContext}</div>
@ -157,13 +160,14 @@ export function App() {
backendStatus={backendStatus}
onDetailViewChanged={handleDataHubDetailViewChanged}
onProjectDataChanged={async projectId => {
await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined);
await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined,
projectId === targetProjectId ? app.activeAssessmentIds : undefined);
}}
onProjectSelected={(project, scanRunIds) => {
onProjectSelected={(project, scanRunIds, assessmentIds) => {
setActiveProjectName(project?.name);
if (project) {
setActiveArea('dashboard');
void app.openProject(project.id, scanRunIds);
void app.openProject(project.id, scanRunIds, assessmentIds);
} else app.setTargetProjectId(undefined);
}}
/>
@ -175,5 +179,6 @@ export function App() {
</section>
</main>
<ImportWorkflowDialog/>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)}/>}
</div>;
}

View File

@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
import { AssessorApiClient } from '../api/assessor-api-client';
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
import type { FindingReview } from '../domain/canonical-model';
import type { ImportProfile } from '../domain/import-profile';
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
import type { Review } from '../domain/review';
@ -18,6 +19,10 @@ import {
LAST_IMPORT_REPORT_STORAGE_KEY, preparePreviewEntriesForReview, previewEntryFromDataset, recognitionOverridesFromResult, sha256File
} from '../features/import-workflow/import-workflow';
import { updateReviewState } from '../features/review-workspace/update-review-state';
import {
hydrateWorkspaceReviews, recordsByOccurrence, reviewFromDatabase, reviewsEqual,
reviewToDatabase, reviewTargetKey
} from '../features/review-workspace/database-review';
import type { DynamicFacetFilter } from '../features/search-filter/dynamic-facets';
import { filterAndSortRecords } from '../features/search-filter/filter-records';
import { applySavedViewState, initialAppState, type AppState, type SavedViewState, type ThemeMode, type ViewMode } from './app-state';
@ -52,7 +57,7 @@ interface AppContextValue extends AppState {
activeAssessmentIds?: string[];
workScopeId: string;
setTargetProjectId: (projectId?: string) => void;
openProject(projectId: string, scanRunIds?: string[]): Promise<boolean>;
openProject(projectId: string, scanRunIds?: string[], assessmentIds?: string[]): Promise<boolean>;
setSelectedRecord(recordId: string): void;
updateReview(recordId: string, patch: Partial<Review>): void;
snapshot(): ReturnType<typeof createSnapshot> | null;
@ -74,7 +79,8 @@ function mixedProfileFor(dataset: ImportedDataset): ImportProfile {
const toolProfiles = [...new Set(dataset.sheets.map(sheet => sheet.toolId).filter((id): id is string => Boolean(id)))]
.map(id => profiles.find(profile => profile.id === id))
.filter((profile): profile is ImportProfile => Boolean(profile && profile.id !== 'generic'));
if (toolProfiles.length < 2) return toolProfiles[0] ?? genericProfile;
const hasPolicyResults = dataset.sheets.some(sheet => sheet.toolId === 'group-policy');
if (toolProfiles.length < 2 && !hasPolicyResults) return toolProfiles[0] ?? genericProfile;
const statusProfiles = [genericProfile, ...toolProfiles];
const statusFilters = [...new Map(statusProfiles.flatMap(profile => profile.presentation.statusFilters)
@ -119,6 +125,8 @@ export function AppProvider({ children }: PropsWithChildren) {
const [activeAssessmentIds, setActiveAssessmentIds] = useState<string[] | undefined>();
const [workScopeId, setWorkScopeId] = useState('');
const openProjectRequest = useRef(0);
const persistedReviews = useRef(new Map<string, FindingReview>());
const reviewSaveQueues = useRef(new Map<string, Promise<void>>());
const [state, setState] = useState<AppState>(() => {
const stored =
(localStorage.getItem('ASSESSOR.theme') as ThemeMode | null) ??
@ -139,6 +147,32 @@ export function AppProvider({ children }: PropsWithChildren) {
const timeoutId = globalThis.setTimeout(persist, 150);
return () => { globalThis.clearTimeout(timeoutId); persist(); };
}, [state.reviews, workScopeId]);
useEffect(() => {
if (!state.dataset || !state.projectId || state.backendStatus !== 'connected') return;
const timer = globalThis.setTimeout(() => {
for (const [occurrenceId, recordIds] of recordsByOccurrence(state.dataset!)) {
const desired = recordIds.map(recordId => state.reviews[recordId]).find(Boolean);
if (!desired) continue;
const key = `occurrence:${occurrenceId}`;
const stored = persistedReviews.current.get(key);
if (stored && reviewsEqual(desired, reviewFromDatabase(stored))) continue;
const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve();
const queued = previous.catch(() => undefined).then(async () => {
const current = persistedReviews.current.get(key);
if (current && reviewsEqual(desired, reviewFromDatabase(current))) return;
const review = reviewToDatabase(desired, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString());
await assessorApi.saveReview(review, current?.revision ?? 0);
persistedReviews.current.set(reviewTargetKey(review.target), review);
}).catch(() => {
setState(current => current.projectId === state.projectId
? { ...current, status: 'Review could not be saved to the database. The local copy was kept.' }
: current);
});
reviewSaveQueues.current.set(key, queued);
}
}, 350);
return () => globalThis.clearTimeout(timer);
}, [state.backendStatus, state.dataset, state.projectId, state.reviews]);
useEffect(() => {
let active = true;
const checkBackend = async () => {
@ -270,7 +304,12 @@ export function AppProvider({ children }: PropsWithChildren) {
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
: undefined;
const workDataset = persistedWorkspace?.dataset ?? dataset;
const reviews = await reviewRepository.load(projectId);
const localReviews = await reviewRepository.load(projectId);
const hydrated = persistedWorkspace
? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews)
: { reviews: localReviews, persisted: new Map<string, FindingReview>() };
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
const activeSheet = workDataset.sheets[0];
const activeProfile = profileFor(workDataset);
const defaults = activeProfile.defaults(activeSheet);
@ -369,7 +408,12 @@ export function AppProvider({ children }: PropsWithChildren) {
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
: undefined;
const workDataset = persistedWorkspace?.dataset ?? dataset;
const reviews = await reviewRepository.load(projectId);
const localReviews = await reviewRepository.load(projectId);
const hydrated = persistedWorkspace
? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews)
: { reviews: localReviews, persisted: new Map<string, FindingReview>() };
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
const activeSheet = workDataset.sheets[0];
const activeProfile = profileFor(workDataset);
const defaults = activeProfile.defaults(activeSheet);
@ -392,32 +436,41 @@ export function AppProvider({ children }: PropsWithChildren) {
}, [importWorkflow.preview, state.backendStatus]);
const openProject = useCallback(async (projectId: string, scanRunIds?: string[]): Promise<boolean> => {
const openProject = useCallback(async (projectId: string, scanRunIds?: string[], selectedAssessmentIds?: string[]): Promise<boolean> => {
const requestId = ++openProjectRequest.current;
const scopeId = createWorkScopeId(projectId, scanRunIds);
const scopeId = createWorkScopeId(projectId, scanRunIds, selectedAssessmentIds);
setTargetProjectId(projectId);
// Der fachliche Scope wechselt atomar mit dem Client-/Timeslot-Auftrag.
// Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens
// weder den vorherigen Timeslot noch den gesamten neuen Client anzeigen.
setActiveScanRunIds(scanRunIds?.length ? [...new Set(scanRunIds)] : undefined);
setActiveAssessmentIds(undefined);
setActiveAssessmentIds(selectedAssessmentIds);
setState(current => ({
...current,
dataset: null,
activeSheetId: '',
projectId,
status: 'Loading saved client …'
}));
try {
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds);
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds);
if (requestId !== openProjectRequest.current) return false;
if (!models.length) {
persistedReviews.current = new Map();
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
return false;
}
if (!dataset) {
setState(current => ({ ...current, status: 'The saved scan reports do not contain any displayable findings.' }));
return false;
persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review]));
setActiveAssessmentIds(assessmentIds);
setWorkScopeId(scopeId);
setState(current => ({ ...current, reviews: {}, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' }));
return true;
}
const reviews = await reviewRepository.load(scopeId);
const localReviews = await reviewRepository.load(scopeId);
const hydrated = hydrateWorkspaceReviews(dataset, models.flatMap(model => model.reviews), localReviews);
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
if (requestId !== openProjectRequest.current) return false;
const activeSheet = dataset.sheets[0];
const profile = profileFor(dataset);
@ -453,7 +506,13 @@ export function AppProvider({ children }: PropsWithChildren) {
const updateReview = useCallback((recordId: string, patch: Partial<Review>) => {
setState(current => {
const { reviews, actionOnly } = updateReviewState(current, recordId, patch);
const selected = current.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === recordId);
const linkedRecordIds = selected?.findingOccurrenceId && current.dataset
? recordsByOccurrence(current.dataset).get(selected.findingOccurrenceId) ?? [recordId]
: [recordId];
let next = { reviews: current.reviews, actionOnly: current.actionOnly };
for (const linkedRecordId of linkedRecordIds) next = updateReviewState(next, linkedRecordId, patch);
const { reviews, actionOnly } = next;
return { ...current, reviews, actionOnly };
});
}, []);

View File

@ -1,5 +1,13 @@
/** A persisted timeslot may be backed by scanner runs, GPO assessments, or both. */
export function hasSelectedTimeslotScope(scanRunIds?: readonly string[], assessmentIds?: readonly string[]): boolean {
return Boolean(scanRunIds?.length || assessmentIds?.length);
}
/** Stable local UI scope for one client state in Work. */
export function createWorkScopeId(projectId: string, scanRunIds?: readonly string[]): string {
export function createWorkScopeId(projectId: string, scanRunIds?: readonly string[], assessmentIds?: readonly string[]): string {
if (!scanRunIds?.length && assessmentIds?.length) {
return `${projectId}::assessments::${[...new Set(assessmentIds)].sort().join('|')}`;
}
if (!scanRunIds?.length) return projectId;
const runs = [...new Set(scanRunIds)].sort();
return `${projectId}::timeslot::${runs.join('|')}`;

View File

@ -4,15 +4,16 @@ import { createPortal } from 'react-dom';
interface Props extends PropsWithChildren {
title: ReactNode;
onClose(): void;
dismissible?: boolean;
}
export function Dialog({ title, onClose, children }: Props) {
export function Dialog({ title, onClose, dismissible = true, children }: Props) {
const dialog = (
<div className="dialog-backdrop" role="presentation" onMouseDown={onClose}>
<div className="dialog-backdrop" role="presentation" onMouseDown={dismissible ? onClose : undefined}>
<section className="dialog-window" role="dialog" aria-modal="true" onMouseDown={event => event.stopPropagation()}>
<header>
<h2>{title}</h2>
<button type="button" onClick={onClose} aria-label="Close window">×</button>
{dismissible && <button type="button" onClick={onClose} aria-label="Close window">×</button>}
</header>
<div className="dialog-body">{children}</div>
</section>

View File

@ -1,8 +1,7 @@
import assessorDarkLogo from '../../assets/branding/assessor-icon-dark-128.png';
import assessorLightLogo from '../../assets/branding/assessor-icon-light-128.png';
import assessorLogo from '../../assets/branding/assessor-icon-transparent.png';
interface EmptyWorkspaceProps { showOpenData?: boolean; }
export function EmptyWorkspace(_props: EmptyWorkspaceProps) {
return <div className="empty-state empty-state-brand" aria-label="ASSESSOR – No dataset open"><div className="empty-state-content"><div className="empty-brand-mark" aria-hidden="true"><span className="empty-brand-logo"><img className="empty-brand-logo-dark" src={assessorDarkLogo} alt=""/><img className="empty-brand-logo-light" src={assessorLightLogo} alt=""/></span><strong>ASSESSOR</strong></div><p className="empty-supported-formats">Open a client in the Datahub and import a scan report there.</p></div></div>;
return <div className="empty-state empty-state-brand" aria-label="ASSESSOR – No dataset open"><div className="empty-state-content"><div className="empty-brand-mark" aria-hidden="true"><span className="empty-brand-logo"><img src={assessorLogo} alt=""/></span><strong>ASSESSOR</strong></div><p className="empty-supported-formats">Open a client in the Datahub and import a scan report there.</p></div></div>;
}

View File

@ -0,0 +1,19 @@
import { Dialog } from '../Dialog/Dialog';
export function LoadingSpinner() {
return <span className="import-spinner" aria-hidden="true"><i/><i/><i/><i/></span>;
}
export function OperationProgress({ label }: { label: string }) {
return <div className="import-progress" role="status" aria-live="polite">
<LoadingSpinner/>
<strong>{label}</strong>
</div>;
}
export function OperationProgressDialog({ label }: { label?: string }) {
if (!label) return null;
return <Dialog title="Processing" dismissible={false} onClose={() => undefined}>
<OperationProgress label={label}/>
</Dialog>;
}

View File

@ -0,0 +1,14 @@
import { LoadingSpinner } from '../OperationProgress/OperationProgress';
interface PanelLoadingStateProps {
message: string;
compact?: boolean;
}
export function PanelLoadingState({ message, compact = false }: PanelLoadingStateProps) {
return <div className={`panel-loading-state${compact ? ' is-compact' : ''}`} role="status" aria-live="polite" aria-label={message}>
<div className="panel-loading-state-content">
<div className="panel-loading-state-message"><LoadingSpinner/><span>{message}</span></div>
</div>
</div>;
}

View File

@ -0,0 +1,30 @@
import { Fragment } from 'react';
import { highlightText } from '../HighlightText/highlight-text';
const pathLabel = /^(?:(?:source|registry|file|folder|directory|binary)\s+)?path$/i;
const placeholder = /^(?:–|—|-|not configured|not available|none)$/i;
export function isPathField(label = ''): boolean {
return pathLabel.test(label.trim());
}
/** Conservative presentation hint; never resolves, opens or changes a path. */
export function isWholePath(value: string): boolean {
const text = value.trim();
if (!text || /[\r\n]/.test(text) || /^\w+:\/\//.test(text)) return false;
return /^(?:[a-z]:[\\/]|\\\\|\/(?!\/)|\.{1,2}[\\/]|%[\w()]+%[\\/]|(?:HKEY_[A-Z_]+|HKLM|HKCU|HKCR|HKU|HKCC|SOFTWARE|SYSTEM)[\\/])/i.test(text)
|| /^[^\s<>:\\/]+[\\/][^<>\r\n]+\.[a-z0-9]{1,12}(?:#.*)?$/i.test(text);
}
/** Inert text, including imported markup. Path labels stay in the UI typeface. */
export function PathText({ value, search = '', label = '' }: { value: string; search?: string; label?: string }) {
const renderPath = (path: string) => <code className="technical-path">{highlightText(path, search)}</code>;
if (isPathField(label) && value.trim() && !placeholder.test(value.trim())) return renderPath(value);
return <>{value.split(/(\r?\n)/).map((line, index) => {
const labeled = /^((?:(?:(?:source|registry|file|folder|directory|binary)\s+)?path|key|computer policy|user policy):\s*)(.+)$/i.exec(line);
if (labeled && !placeholder.test(labeled[2].trim()) && (isPathField(labeled[1].split(':')[0]) || isWholePath(labeled[2]))) {
return <Fragment key={index}>{highlightText(labeled[1], search)}{renderPath(labeled[2])}</Fragment>;
}
return <Fragment key={index}>{isWholePath(line) ? renderPath(line) : highlightText(line, search)}</Fragment>;
})}</>;
}

View File

@ -8,12 +8,14 @@ import {
} from '../../shared/markdown';
import { highlightText } from '../HighlightText/highlight-text';
import { ResizableResultTable } from '../ResizableResultTable/ResizableResultTable';
import { PathText, isPathField, isWholePath } from '../PathText/PathText';
interface Props {
value: string;
search?: string;
onResults?: () => void;
resizableTables?: boolean;
label?: string;
}
const formattedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a)\b/i;
@ -37,7 +39,7 @@ const trailingPunctuation = /[.,;:!?)\]}]+$/;
const resultTriggerPattern = /(Show\s+Assessment\s+Evidence|Show\s+Rule\s+Result\s+XML|(?:--?\s*)?\bMore\b)/gi;
function linkifyText(text: string, key: string, search?: string): ReactNode {
if (!text || !/https?:\/\//i.test(text)) return highlightText(text, search);
if (!text || !/https?:\/\//i.test(text)) return <PathText value={text} search={search} />;
const parts: ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(bareUrl)) {
@ -154,7 +156,7 @@ function renderInline(tokens: InlineToken[], key: string, search?: string, onRes
switch (token.kind) {
case 'strong': return <strong key={tokenKey}>{renderInline(token.children, tokenKey, search, onResults)}</strong>;
case 'emphasis': return <em key={tokenKey}>{renderInline(token.children, tokenKey, search, onResults)}</em>;
case 'code': return <code key={tokenKey}>{highlightText(token.text, search)}</code>;
case 'code': return isWholePath(token.text) ? <PathText key={tokenKey} value={token.text} search={search} /> : <code key={tokenKey}>{highlightText(token.text, search)}</code>;
case 'link': {
const href = safeHref(token.href);
const textLabel = token.children.map(c => c.kind === 'text' ? c.text : '').join('');
@ -194,7 +196,8 @@ function renderBlock(block: MarkdownBlock, key: string, search?: string, onResul
}
}
export function RichTextValue({ value, search, onResults, resizableTables = false }: Props) {
export function RichTextValue({ value, search, onResults, resizableTables = false, label }: Props) {
if (isPathField(label) || isWholePath(value)) return <p><PathText value={value} search={search} label={label} /></p>;
let source = value;
if (encodedTags.test(source)) source = decodeEntities(source);

View File

@ -6,6 +6,7 @@ import { resultStatusFieldFor } from '../../features/linked-results/result-messa
import { highlightText } from '../HighlightText/highlight-text';
import { BookmarkIcon } from '../BookmarkIcon';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { policyFindingCategories } from '../../profiles/group-policy/categories';
export interface TileBadge {
label: string;
@ -32,13 +33,20 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
});
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value }));
const gpoBadges = policyFindingCategories(record, fallbackToolId).map(value => ({ label: 'GPO Category', value, text: value }));
const scopeBadges = toolIdForRecord(record, fallbackToolId) === 'group-policy'
? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim())
.map(field => ({ label: 'Scope', value: field.value.trim(), text: `Scope: ${field.value.trim()}` })) : [];
const orderedBadges = [
...badges.filter(badge => badge.label === 'Status' || badge.label === 'Category'),
...cisBadges,
...gpoBadges,
...scopeBadges,
...badges.filter(badge => badge.label !== 'Status' && badge.label !== 'Category')
];
return orderedBadges.filter((badge, index) => !orderedBadges.slice(0, index).some(previous => previous.value.trim().toLocaleLowerCase('en-US') === badge.value.trim().toLocaleLowerCase('en-US')));
const key = (badge: TileBadge) => (badge.label === 'Scope' ? badge.text : badge.value).trim().toLocaleLowerCase('en-US');
return orderedBadges.filter((badge, index) => !orderedBadges.slice(0, index).some(previous => key(previous) === key(badge)));
}
export function compactTileBadges(badges: TileBadge[], limit?: number): TileBadge[] {
@ -86,6 +94,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
return (
<article
className={`data-tile severity-${severity}${bookmarked ? ' is-bookmarked' : ''}`}
data-tool={recordToolId}
tabIndex={0}
role="button"
onClick={onOpen}
@ -100,7 +109,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
und Investigate in der Kachelansicht identisch aus. */
? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' || badge.label === 'GPO Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
</div>
</div>
<div className="data-tile-actions">

View File

@ -0,0 +1,3 @@
import packageMetadata from '../../package.json';
export const ASSESSOR_PRODUCT_VERSION = packageMetadata.version;

View File

@ -289,6 +289,8 @@ export interface FindingReview {
id: string;
target: ReviewTarget;
workflowStatus: ReviewWorkflowStatus;
/** Exact FindingReader label when it is more specific than the canonical workflow state. */
workflowLabel?: string;
priority: ReviewPriority;
includeInActionPlan: boolean;
owner?: string;

View File

@ -35,6 +35,8 @@ export interface DatasetSheet {
sourceSheet?: string;
/** Erklaerender Quelltext, der im Results-Fenster direkt vor diesem Blatt steht. */
resultPreamble?: string;
/** Columns whose differing evidence should be emphasized in Results. */
resultEmphasisColumns?: string[];
/** Stabiler Index der Quelltabelle innerhalb der Importdatei. */
sourceTableIndex?: number;
workbookIndex?: number;

View File

@ -30,6 +30,9 @@ import type { CanonicalOutcome } from './canonical-model';
export const DEVIATION_PREDICATE_VERSION = '1.1.0';
// GPO comparisons use the stricter policyComparisonDisposition evidence contract:
// only configured mismatches are deviations; missing settings and conflicts are separate.
/**
* Outcome-Werte, die ohne festgestellte Exposition als Abweichung gelten.
*/

View File

@ -8,6 +8,10 @@ export interface RecordField {
export interface FindingRecord {
id: string;
/** Stable database identity of the projected canonical finding occurrence. */
findingOccurrenceId?: string;
/** Stable database identity shared by occurrences of the same finding. */
findingDefinitionId?: string;
index: number;
title: string;
fields: RecordField[];
@ -22,4 +26,3 @@ export interface FindingRecord {
*/
highlighted?: boolean;
}

View File

@ -2,7 +2,7 @@ import type { GpoSecretIndicatorStatus } from './gpo-ingestion';
export interface GpoBackupMetadataDocument {
version: 1;
parser: { id: 'gpo-backup-metadata-xml'; version: '1.0.0' };
parser: { id: 'gpo-backup-metadata-xml'; version: '1.0.0' | '1.1.0' | '1.2.0' };
sourcePath: string;
sourceSha256: string;
status: 'complete' | 'partial' | 'invalid';

View File

@ -5,6 +5,10 @@ export const GPO_POLICY_IDENTITY_RESOLVER = {
version: '1.0.0'
} as const;
export const GPO_NATIVE_POLICY_IDENTITY_RESOLVER = {
id: 'gpo-native-policy-target', version: '1.0.0'
} as const;
export const ADMX_POLICY_CATALOG_PARSER = {
id: 'admx-policy-metadata-catalog',
version: '1.0.0'
@ -78,9 +82,15 @@ export type AdmxPolicyResolution =
export interface GpoTechnicalPolicyIdentity {
version: 1;
id: string;
resolver: typeof GPO_POLICY_IDENTITY_RESOLVER;
resolver: typeof GPO_POLICY_IDENTITY_RESOLVER | typeof GPO_NATIVE_POLICY_IDENTITY_RESOLVER;
scope: ResolvableGpoPolicyScope;
target: GpoRegistryTechnicalTarget;
target: GpoRegistryTechnicalTarget | {
kind: 'security-template' | 'advanced-audit-policy';
section: string;
key: string;
hive?: never;
valueName?: never;
};
admx: AdmxPolicyResolution;
}
@ -132,12 +142,17 @@ export function admxPolicyCatalogValidationErrors(catalog: AdmxPolicyCatalog): s
export function gpoTechnicalPolicyIdentityValidationErrors(identity: GpoTechnicalPolicyIdentity): string[] {
const errors: string[] = [];
if (identity.version !== 1 || !/^gpo-policy-identity-[a-f0-9]{32}$/.test(identity.id)
|| identity.resolver.id !== GPO_POLICY_IDENTITY_RESOLVER.id
|| ![GPO_POLICY_IDENTITY_RESOLVER.id, GPO_NATIVE_POLICY_IDENTITY_RESOLVER.id].includes(identity.resolver.id)
|| identity.resolver.version !== GPO_POLICY_IDENTITY_RESOLVER.version) {
errors.push('Die technische Policy-Identität besitzt eine ungültige Version oder Resolverprovenienz.');
}
const expectedHive = identity.scope === 'computer' ? 'HKEY_LOCAL_MACHINE' : 'HKEY_CURRENT_USER';
if (identity.target.hive !== expectedHive || !safeTechnicalText(identity.target.key)
const registry = identity.target.kind === 'registry-key' || identity.target.kind === 'registry-value';
if (!['computer', 'user'].includes(identity.scope)
|| identity.resolver.id !== (registry ? GPO_POLICY_IDENTITY_RESOLVER.id : GPO_NATIVE_POLICY_IDENTITY_RESOLVER.id)
|| !['registry-key', 'registry-value', 'security-template', 'advanced-audit-policy'].includes(identity.target.kind)
|| (registry && identity.target.hive !== expectedHive) || !safeTechnicalText(identity.target.key)
|| (!registry && (!('section' in identity.target) || !safeTechnicalText(identity.target.section) || identity.target.hive !== undefined))
|| (identity.target.kind === 'registry-value' && identity.target.valueName === undefined)
|| (identity.target.kind === 'registry-key' && identity.target.valueName !== undefined)) {
errors.push('Die technische Registry-Zielidentität ist ungültig.');

View File

@ -5,7 +5,7 @@ import { gpoTechnicalPolicyIdentityValidationErrors } from './gpo-policy-identit
export const GPO_POLICY_SNAPSHOT_AGGREGATOR = {
id: 'gpo-policy-snapshot-aggregation',
version: '1.0.0'
version: '1.1.0'
} as const;
export type EnvironmentProfileScope = 'computer' | 'user' | 'mixed';
@ -18,6 +18,7 @@ export interface EnvironmentProfileContext {
productVersion: string;
role: string;
scope: EnvironmentProfileScope;
sourceObservationIds?: string[];
}
export interface EnvironmentProfile {
@ -130,6 +131,11 @@ export function environmentProfileValidationErrors(profile: EnvironmentProfile):
errors.push('Das Environment Profile enthaelt einen ungueltigen oder doppelten Produktkontext.');
}
contextIds.add(context.id);
if (context.sourceObservationIds !== undefined && (!Array.isArray(context.sourceObservationIds)
|| !context.sourceObservationIds.length || !context.sourceObservationIds.every(validText)
|| new Set(context.sourceObservationIds).size !== context.sourceObservationIds.length)) {
errors.push('Die Quellenreferenzen eines Environment-Kontexts sind ungueltig.');
}
}
return [...new Set(errors)];
}
@ -183,4 +189,3 @@ export function storedPolicySnapshotAggregationValidationErrors(record: StoredPo
}
return [...new Set(errors)];
}

View File

@ -65,6 +65,7 @@ export interface IngestionJob {
}
export interface IngestionJobClaim {
projectId?: string;
workerId: string;
now: string;
leaseDurationMs: number;

View File

@ -3,7 +3,7 @@ import type { GpoPolicyObservation } from './gpo-policy';
export const MICROSOFT_BASELINE_SETTINGS_ANALYZER = {
id: 'microsoft-baseline-gpo-settings',
version: '1.0.0'
version: '1.1.0'
} as const;
export interface MicrosoftSecurityBaselineSetting {
@ -22,7 +22,7 @@ export interface MicrosoftSecurityBaselineAnalysis {
id: string;
baselineId: string;
packageSha256: string;
analyzer: typeof MICROSOFT_BASELINE_SETTINGS_ANALYZER;
analyzer: { id: typeof MICROSOFT_BASELINE_SETTINGS_ANALYZER.id; version: '1.0.0' | '1.1.0' };
analyzedAt: string;
status: 'complete' | 'partial';
admxCatalog: AdmxPolicyCatalog;
@ -42,7 +42,7 @@ export function microsoftSecurityBaselineAnalysisValidationErrors(analysis: Micr
const errors: string[] = [];
if (analysis.version !== 1 || !analysis.id.trim() || !analysis.baselineId.trim() || !/^[a-f0-9]{64}$/.test(analysis.packageSha256)
|| analysis.analyzer.id !== MICROSOFT_BASELINE_SETTINGS_ANALYZER.id
|| analysis.analyzer.version !== MICROSOFT_BASELINE_SETTINGS_ANALYZER.version
|| !['1.0.0', '1.1.0'].includes(analysis.analyzer.version)
|| !Number.isFinite(Date.parse(analysis.analyzedAt)) || analysis.admxCatalog.source.baselineId !== analysis.baselineId
|| analysis.admxCatalog.source.packageSha256 !== analysis.packageSha256) {
errors.push('Die Microsoft-Baselineanalyse besitzt ungültige Identität oder Provenienz.');
@ -53,7 +53,9 @@ export function microsoftSecurityBaselineAnalysisValidationErrors(analysis: Micr
}
for (const setting of analysis.settings) {
if (setting.version !== 1 || !setting.id.trim() || setting.baselineId !== analysis.baselineId || !setting.backupId.trim()
|| setting.observation.deliveryMechanism !== 'registry-policy' || setting.observation.state !== 'instruction'
|| !['registry-policy', 'security-template', 'advanced-audit-policy'].includes(setting.observation.deliveryMechanism)
|| (setting.observation.deliveryMechanism === 'registry-policy' ? setting.observation.state !== 'instruction'
: !['configured', 'conflicted'].includes(setting.observation.state))
|| !setting.observation.sourcePath.trim() || !/^[a-f0-9]{64}$/.test(setting.observation.sourceSha256)
|| setting.observation.values.length !== 1 || setting.observation.effectiveState !== 'unknown') {
errors.push('Die Microsoft-Baselineanalyse enthält eine ungültige Registry-Policy-Einstellung.');

View File

@ -1,6 +1,6 @@
import type { StoredGpoPolicyAnalysis } from './gpo-analysis-persistence';
import type { EnvironmentProfile, StoredPolicySnapshotAggregation } from './gpo-snapshot-aggregation';
import type { StoredPolicyEvaluation } from './policy-evaluation';
import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from './policy-evaluation';
import type { StoredPolicyFindingRun } from './policy-finding';
import type { ImportProcessingRun } from './ingestion-persistence';
import {
@ -368,7 +368,9 @@ function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisAc
if (aggregation.run.status !== 'complete') reviewOnly.push(`policy-action-aggregation-${aggregation.run.status}`);
const baselineAnalysisIds = uniqueSorted(baselineIds);
const existing = input.evaluations.find(record => record.run.aggregationRunId === aggregation.run.id
&& sameIds(record.run.baselineAnalysisIds, baselineAnalysisIds));
&& sameIds(record.run.baselineAnalysisIds, baselineAnalysisIds)
&& record.run.evaluator.id === POLICY_EVALUATION_ENGINE.id
&& record.run.evaluator.version === POLICY_EVALUATION_ENGINE.version);
const reasonCodes = uniqueSorted([...blocking, ...reviewOnly]);
return {
version: 1,

View File

@ -0,0 +1,14 @@
import type { PolicyEvaluationResult } from './policy-evaluation';
/** Read-time reporting contract; persisted evaluator evidence remains unchanged. */
export function policyComparisonDisposition(result: PolicyEvaluationResult): 'misconfigured' | 'not-configured' | undefined {
const { baseline, actual, relation } = result.comparator;
if (result.baselineState !== 'explicit-recommendation' || !baseline || baseline.kind === 'none'
|| (baseline.kind === 'text' && !baseline.value.trim())
|| result.diagnosticCodes.includes('policy-evaluation-baseline-consensus-conflict')
|| result.classification !== 'confirmed_configured_deviation'
|| !['different', 'weaker'].includes(relation)) return undefined;
if (result.customerState === 'missing' && (!actual || actual.kind === 'none')) return 'not-configured';
if (result.customerState === 'configured' && actual && actual.kind !== 'none') return 'misconfigured';
return undefined;
}

View File

@ -6,7 +6,7 @@ import {
type PolicyEvaluationRuleCatalog
} from './policy-evaluation-rules';
export const POLICY_EVALUATION_ENGINE = { id: 'policy-baseline-evaluation', version: '1.1.0' } as const;
export const POLICY_EVALUATION_ENGINE = { id: 'policy-baseline-evaluation', version: '1.3.3' } as const;
export const POLICY_COMPARATOR_CATALOG = { id: 'policy-comparator-catalog', version: '1.1.0' } as const;
export const POLICY_DEPENDENCY_RULESET = { id: 'policy-dependency-rules', version: '1.1.0' } as const;
export const POLICY_RISK_RULESET = { id: 'policy-risk-rules', version: '1.1.0' } as const;
@ -63,7 +63,11 @@ export interface PolicyEvaluationResult {
version: 1;
id: string;
evaluationRunId: string;
aggregationStateId: string;
aggregationStateId?: string;
baselineAnalysisId: string;
baselineId: string;
baselineLabel: string;
customerState: 'configured' | 'missing' | 'conflicted' | 'indeterminate';
identity: GpoTechnicalPolicyIdentity;
classification: PolicyEvaluationClassification;
comparator: PolicyComparatorOutcome;
@ -154,14 +158,19 @@ export function storedPolicyEvaluationValidationErrors(record: StoredPolicyEvalu
const resultIds = new Set<string>();
for (const result of results) {
if (result.version !== 1 || !validText(result.id) || resultIds.has(result.id) || result.evaluationRunId !== run.id
|| !validText(result.aggregationStateId) || !uniqueSorted(result.contextIds) || !uniqueSorted(result.observationIds)
|| (result.aggregationStateId !== undefined && !validText(result.aggregationStateId))
|| !validText(result.baselineAnalysisId) || !run.baselineAnalysisIds.includes(result.baselineAnalysisId)
|| !validText(result.baselineId) || !validText(result.baselineLabel)
|| !['configured', 'missing', 'conflicted', 'indeterminate'].includes(result.customerState)
|| !uniqueSorted(result.contextIds) || !uniqueSorted(result.observationIds)
|| !uniqueSorted(result.backupInstanceIds) || !uniqueSorted(result.baselineAnalysisIds)
|| !uniqueSorted(result.baselineSettingIds) || !uniqueSorted(result.ruleIds)
|| !['configured_match', 'confirmed_configured_deviation', 'not_applicable', 'review_required'].includes(result.classification)
|| !['explicit-recommendation', 'explicitly-not-applicable', 'no-recommendation', 'catalog-gap',
'unresolved-identity', 'parser-gap', 'unsupported-setting'].includes(result.baselineState)
|| (['configured_match', 'confirmed_configured_deviation'].includes(result.classification)
&& (result.baselineState !== 'explicit-recommendation' || !result.ruleIds.length || !result.riskRuleIds.length))
&& result.baselineState !== 'explicit-recommendation')
|| (!result.ruleIds.length && result.riskRuleIds.length > 0)
|| !uniqueSorted(result.dependencyRuleIds) || !uniqueSorted(result.riskRuleIds)
|| result.effectiveState !== 'unknown'
|| (result.findingEligibility === 'eligible' && result.classification !== 'confirmed_configured_deviation')
@ -170,6 +179,11 @@ export function storedPolicyEvaluationValidationErrors(record: StoredPolicyEvalu
|| (result.classification === 'review_required' && result.findingEligibility !== 'ineligible')) {
errors.push('Ein Policy-Evaluation-Ergebnis ist ungueltig oder doppelt.');
}
if (result.baselineAnalysisIds.length !== 1 || result.baselineAnalysisIds[0] !== result.baselineAnalysisId
|| (result.customerState === 'missing' && (result.aggregationStateId !== undefined || result.observationIds.length > 0))
|| (result.customerState !== 'missing' && !result.aggregationStateId)) {
errors.push('Ein Policy-Evaluation-Ergebnis besitzt eine inkonsistente Ist- oder Baseline-Referenz.');
}
resultIds.add(result.id);
}
const evidenceIds = new Set<string>();

View File

@ -9,9 +9,10 @@
* `is_ioe` ist per RESULT_CONTRACT niemals per Default true für
* `compliance_check` oder `policy_deviation`.
*
* GPO-/Baseline-Ergebnisse (`policy_deviation`) werden ausschließlich durch
* die versionierte Finding Factory projiziert und daher bewusst nicht aus
* einer Tool-ID abgeleitet; `deriveResultType` liefert für sie nie einen Wert.
* GPO-/Baseline-Ergebnisse (`policy_deviation`) entstehen aus der versionierten
* Policy-Evaluation. Die Finding Factory darf sie um freigegebene Risiko- und
* Severity-Aussagen anreichern; `deriveResultType` leitet sie nie aus einer
* bloßen Tool-ID ab.
*/
import type { CanonicalOutcome, CanonicalTechnicalExecutionState } from './canonical-model';

View File

@ -0,0 +1,134 @@
import type { JsonValue } from './canonical-model';
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '1.0.0';
export type ResultDbSupplementalDisclosureClass =
| 'aggregate'
| 'configuration-state'
| 'technical-metadata';
export interface ResultDbSupplementalDisclosure {
policyVersion: typeof RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION;
disclosureClass: ResultDbSupplementalDisclosureClass;
property: string;
title: string;
subjectType: string;
value: JsonValue;
valueType: EnvironmentValueType;
state: EnvironmentCollectionState;
}
interface DisclosureRule {
toolId: string;
property: string;
title: string;
subjectType: string;
disclosureClass: ResultDbSupplementalDisclosureClass;
disclose(value: JsonValue): JsonValue | undefined;
}
function finiteNumber(value: JsonValue, minimum: number, maximum: number): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) return undefined;
return value;
}
function integer(value: JsonValue, minimum: number, maximum: number): number | undefined {
const numeric = finiteNumber(value, minimum, maximum);
return numeric !== undefined && Number.isInteger(numeric) ? numeric : undefined;
}
function booleanValue(value: JsonValue): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
function productVersion(value: JsonValue): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim();
return /^\d+(?:\.\d+){1,3}(?:[-+][a-z0-9.-]+)?$/i.test(normalized) ? normalized : undefined;
}
const pingCastleRules: DisclosureRule[] = [
{
toolId: 'pingcastle', property: 'report.engine_version', title: 'PingCastle engine version',
subjectType: 'scanner-report', disclosureClass: 'technical-metadata', disclose: productVersion
},
...['risk.domain', 'risk.global', 'risk.stale_objects', 'risk.privileged_accounts', 'risk.trusts', 'risk.anomalies']
.map((property): DisclosureRule => ({
toolId: 'pingcastle', property, title: property === 'risk.domain' || property === 'risk.global'
? 'Domain risk score'
: `${property.slice('risk.'.length).replaceAll('_', ' ')} score`,
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
disclose: value => finiteNumber(value, 0, 100)
})),
{
toolId: 'pingcastle', property: 'domain.functional_level', title: 'Domain functional level',
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
disclose: value => integer(value, 2000, 9999)
},
{
toolId: 'pingcastle', property: 'forest.functional_level', title: 'Forest functional level',
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
disclose: value => integer(value, 2000, 9999)
},
{
toolId: 'pingcastle', property: 'forest.schema_version', title: 'Forest schema version',
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
disclose: value => integer(value, 0, 10000)
},
{
toolId: 'pingcastle', property: 'domain.controller_count', title: 'Domain controller count',
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
disclose: value => integer(value, 0, 1000000)
},
{
toolId: 'pingcastle', property: 'domain.recycle_bin_enabled', title: 'AD Recycle Bin enabled',
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state', disclose: booleanValue
},
{
toolId: 'pingcastle', property: 'entra.configuration_detected', title: 'Entra ID configuration detected',
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state', disclose: booleanValue
},
{
toolId: 'pingcastle', property: 'logging.wef.configuration_count', title: 'WEF configuration count',
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
disclose: value => integer(value, 0, 1000000)
},
{
toolId: 'pingcastle', property: 'kerberos.krbtgt.version', title: 'krbtgt version',
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
disclose: value => integer(value, 0, 1000000)
}
];
const disclosureRules = new Map(
pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule])
);
/**
* Fail-closed privacy boundary for supplemental scanner context in Result DB.
* Findings use their canonical projection. Environment/details records only
* cross this boundary when a tool/property pair has an explicit rule and its
* normalized value conforms to the narrow non-identifying value contract.
*/
export function discloseSupplementalObservation(
observation: EnvironmentObservation
): ResultDbSupplementalDisclosure | undefined {
const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US');
const rule = disclosureRules.get(`${toolId}\u0000${observation.property}`);
if (!rule) return undefined;
const disclosedValue = rule.disclose(observation.normalizedValue ?? observation.originalValue);
if (disclosedValue === undefined) return undefined;
return {
policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION,
disclosureClass: rule.disclosureClass,
property: rule.property,
title: rule.title,
subjectType: rule.subjectType,
value: disclosedValue,
valueType: typeof disclosedValue === 'number' ? 'number'
: typeof disclosedValue === 'boolean' ? 'boolean'
: 'string',
state: observation.state
};
}

View File

@ -21,12 +21,26 @@ import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentVal
import type { ImportArtifact, ImportProcessingRun } from './ingestion-persistence';
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis, StoredGpoPolicyIdentityResolution } from './gpo-analysis-persistence';
import type { GpoPolicyObservation } from './gpo-policy';
import {
POLICY_COMPARATOR_CATALOG,
POLICY_EVALUATION_ENGINE,
type PolicyEvaluationResult,
type PolicyEvaluationRun,
type StoredPolicyEvaluation
} from './policy-evaluation';
import type { StoredPolicySnapshotAggregation } from './gpo-snapshot-aggregation';
import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-contract-mapping';
import { isDeviation } from './deviation-predicate';
import { policyComparisonDisposition } from './policy-comparison-disposition';
import { severityRank } from './severity-order';
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
import {
discloseSupplementalObservation,
type ResultDbSupplementalDisclosureClass
} from './result-db-supplemental-disclosure';
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation';
export type UnifiedResultAudience = 'result-db' | 'internal-analysis';
export type UnifiedOutcome = CanonicalOutcome;
export type TechnicalExecutionState = CanonicalTechnicalExecutionState;
export type UnifiedReviewStatus = 'unreviewed' | 'open' | 'in-progress' | 'resolved';
@ -82,6 +96,8 @@ export interface UnifiedResultProjection {
environmentNormalizedValue?: JsonValue;
environmentSectionKey?: string;
environmentAttributes?: Record<string, JsonValue>;
supplementalDisclosurePolicyVersion?: string;
supplementalDisclosureClass?: ResultDbSupplementalDisclosureClass;
policyScope?: GpoPolicyObservation['scope'];
policyDeliveryMechanism?: GpoPolicyObservation['deliveryMechanism'];
policyState?: GpoPolicyObservation['state'];
@ -90,6 +106,12 @@ export interface UnifiedResultProjection {
policyValues?: JsonValue[];
policyDiagnosticCodes?: string[];
policyBackupInstanceId?: string;
policyBaselineId?: string;
policyBaselineLabel?: string;
policyBaselineValue?: JsonValue;
policyActualValue?: JsonValue;
policyComparisonRelation?: string;
policyCustomerState?: PolicyEvaluationResult['customerState'];
title: string;
description?: string;
recommendation?: string;
@ -135,6 +157,7 @@ export interface UnifiedResultProjection {
}
export interface UnifiedResultQuery {
audience?: UnifiedResultAudience;
projectId?: string;
assessmentId?: string;
assessmentIds?: string[];
@ -204,6 +227,7 @@ export interface ScannerUnifiedResultSource {
}
export interface PolicyUnifiedResultSource {
comparison?: PolicyEvaluationResult;
definition: FindingDefinition;
occurrence: PolicyFindingOccurrence;
}
@ -374,7 +398,7 @@ export function createScannerUnifiedResult(source: ScannerUnifiedResultSource):
}
export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): UnifiedResultProjection {
const { definition, occurrence } = source;
const { definition, occurrence, comparison } = source;
return {
resultProjectionId: `policy-evaluation:${occurrence.id}`,
sourceKind: 'policy-evaluation',
@ -383,7 +407,7 @@ export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): Un
collectionRunId: occurrence.evaluationRunId,
gpoSnapshotId: occurrence.snapshotId,
evaluationRunId: occurrence.evaluationRunId,
sourceTool: 'group-policy-baseline',
sourceTool: 'GPOs',
sourceProfileId: occurrence.environmentProfileId,
sourceProfileVersion: String(occurrence.environmentProfileRevision),
mappingRuleVersion: 'policy-finding-factory-1.0.0',
@ -401,11 +425,12 @@ export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): Un
severityOriginal: occurrence.severity,
severityNormalized: occurrence.severity,
outcomeOriginal: occurrence.classification,
outcomeNormalized: occurrence.outcome,
outcomeNormalized: comparison ? policyEvaluationOutcome(comparison) : 'not_evaluated',
technicalExecutionState: 'completed',
resultType: 'policy_deviation',
isIoe: false,
isDeviation: true,
isDeviation: comparison ? policyComparisonDisposition(comparison) === 'misconfigured' : false,
policyCustomerState: comparison?.customerState,
effectiveState: 'unknown_effective_state',
lifecycleState: 'unknown',
mappingConfidence: 1,
@ -424,13 +449,167 @@ export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): Un
categoryLabel: definition.category ?? 'group-policy-baseline',
categoryType: 'canonical',
isPrimary: true,
sourceTool: 'group-policy-baseline',
sourceTool: 'GPOs',
sourceProfileId: occurrence.environmentProfileId,
sourceProfileVersion: String(occurrence.environmentProfileRevision)
}]
};
}
function policyEvaluationTitle(result: PolicyEvaluationResult): string {
if (result.identity.admx.status === 'matched') return result.identity.admx.reference.policyName;
const target = result.identity.target;
if (target.kind === 'registry-key' || target.kind === 'registry-value') {
return [target.key, target.valueName].filter(Boolean).join(' · ');
}
return `${'section' in target ? target.section : target.kind} · ${target.key}`;
}
function policyEvaluationOutcome(result: PolicyEvaluationResult): UnifiedOutcome {
if (policyComparisonDisposition(result) === 'not-configured') return 'informational';
if (result.classification === 'configured_match') return 'pass';
if (policyComparisonDisposition(result) === 'misconfigured') return 'fail';
if (result.classification === 'not_applicable') return 'not_applicable';
return 'warning';
}
function policyDeliveryMechanism(result: PolicyEvaluationResult): GpoPolicyObservation['deliveryMechanism'] {
if (result.identity.target.kind === 'security-template') return 'security-template';
if (result.identity.target.kind === 'advanced-audit-policy') return 'advanced-audit-policy';
return 'registry-policy';
}
export interface PolicyEvaluationUnifiedResultContext {
assessmentId?: string;
importArtifactId?: string;
snapshotId?: string;
}
/**
* Projects a persisted baseline comparison as a normal Result DB row. Finding
* eligibility only controls risk/severity enrichment by the Finding Factory;
* it does not hide a deterministic PASS/FAIL compliance result.
*/
export function createPolicyEvaluationUnifiedResult(
run: PolicyEvaluationRun,
result: PolicyEvaluationResult,
context: PolicyEvaluationUnifiedResultContext = {}
): UnifiedResultProjection {
const outcomeNormalized = policyEvaluationOutcome(result);
return {
resultProjectionId: `policy-evaluation-comparison:${result.id}`,
sourceKind: 'policy-evaluation',
sourceResultId: result.id,
projectId: run.projectId,
assessmentId: context.assessmentId,
collectionRunId: run.aggregationRunId,
gpoSnapshotId: context.snapshotId,
evaluationRunId: run.id,
sourceTool: 'GPOs',
sourceProfileId: run.evaluator.id,
sourceProfileVersion: run.evaluator.version,
mappingRuleVersion: POLICY_COMPARATOR_CATALOG.version,
normalizationRuleVersion: POLICY_COMPARATOR_CATALOG.version,
presentationVersion: 'gpo-policy-evaluation-1.1.0',
processingCompatibilityKey: 'gpo-policy-evaluation-v1',
processingStatus: 'current',
reprocessingAvailable: true,
sourceRuleId: result.identity.id,
importArtifactId: context.importArtifactId,
sourceViewId: result.aggregationStateId,
title: policyEvaluationTitle(result),
severityOriginal: null,
severityNormalized: 'unknown',
outcomeOriginal: result.classification,
outcomeNormalized,
technicalExecutionState: 'completed',
resultType: 'policy_deviation',
isIoe: false,
isDeviation: policyComparisonDisposition(result) === 'misconfigured',
effectiveState: result.classification === 'review_required'
? 'incomplete_observation'
: result.classification,
lifecycleState: 'unknown',
mappingConfidence: 1,
applicabilityState: result.baselineState,
completenessState: result.diagnosticCodes.includes('policy-evaluation-baseline-incomplete') ? 'partial' : 'complete',
scopeState: 'known',
effectivenessVerification: 'not-verified',
scopeConfidence: 1,
reviewStatus: result.classification === 'review_required' ? 'open' : 'unreviewed',
importedAt: run.completedAt,
evaluatedAt: run.completedAt,
createdAt: run.completedAt,
policyScope: result.identity.scope,
policyDeliveryMechanism: policyDeliveryMechanism(result),
policyIdentityStatus: 'resolved',
policyIdentityKey: result.identity.id,
policyDiagnosticCodes: result.diagnosticCodes,
policyBaselineId: result.baselineId,
policyBaselineLabel: result.baselineLabel,
policyBaselineValue: result.comparator.baseline as JsonValue | undefined,
policyActualValue: result.comparator.actual as JsonValue | undefined,
policyComparisonRelation: result.comparator.relation,
policyCustomerState: result.customerState,
categories: [{
namespace: 'group-policy',
categoryCode: 'GPOs',
categoryLabel: 'GPOs',
categoryType: 'canonical',
isPrimary: true,
sourceTool: 'GPOs',
sourceProfileId: run.evaluator.id,
sourceProfileVersion: run.evaluator.version
}]
};
}
export function selectCurrentPolicyEvaluations(records: StoredPolicyEvaluation[]): StoredPolicyEvaluation[] {
const selected = new Map<string, StoredPolicyEvaluation>();
for (const record of records) {
if (record.run.evaluator.id !== POLICY_EVALUATION_ENGINE.id
|| record.run.evaluator.version !== POLICY_EVALUATION_ENGINE.version) continue;
const current = selected.get(record.run.aggregationRunId);
const replacesCurrent = !current
|| record.run.baselineAnalysisIds.length > current.run.baselineAnalysisIds.length
|| (record.run.baselineAnalysisIds.length === current.run.baselineAnalysisIds.length
&& `${record.run.completedAt}\u001f${record.run.id}` > `${current.run.completedAt}\u001f${current.run.id}`);
if (replacesCurrent) selected.set(record.run.aggregationRunId, record);
}
return [...selected.values()].sort((left, right) => left.run.completedAt.localeCompare(right.run.completedAt));
}
export function createCurrentPolicyEvaluationUnifiedResults(
evaluations: StoredPolicyEvaluation[],
aggregations: StoredPolicySnapshotAggregation[],
analyses: StoredGpoPolicyAnalysis[],
artifacts: ImportArtifact[],
excludedEvaluationResultIds: ReadonlySet<string> = new Set()
): UnifiedResultProjection[] {
const aggregationById = new Map(aggregations.map(record => [record.run.id, record]));
const analysisById = new Map(analyses.map(record => [record.run.id, record]));
const artifactById = new Map(artifacts.map(record => [record.id, record]));
return selectCurrentPolicyEvaluations(evaluations).flatMap(record => {
const aggregation = aggregationById.get(record.run.aggregationRunId);
if (!aggregation) return [];
const sourceArtifacts = aggregation.run.sourceAnalysisRunIds
.map(id => analysisById.get(id)?.run.artifactId)
.filter((id): id is string => Boolean(id))
.map(id => artifactById.get(id))
.filter((item): item is ImportArtifact => Boolean(item));
const assessmentIds = [...new Set(sourceArtifacts.map(item => item.assessmentId).filter(Boolean))];
const artifactIds = [...new Set(sourceArtifacts.map(item => item.id))];
const context: PolicyEvaluationUnifiedResultContext = {
assessmentId: assessmentIds.length === 1 ? assessmentIds[0] : undefined,
importArtifactId: artifactIds.length === 1 ? artifactIds[0] : undefined,
snapshotId: aggregation.run.snapshotId
};
return record.results
.filter(result => !excludedEvaluationResultIds.has(result.id))
.map(result => createPolicyEvaluationUnifiedResult(record.run, result, context));
});
}
function policyObservationTitle(observation: GpoPolicyObservation): string {
const section = observation.identity.section.trim();
const key = observation.identity.key.trim();
@ -579,10 +758,10 @@ function environmentPropertyGroup(property: string): string {
return property.split('.')[0]?.trim() || 'environment';
}
export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultSource): UnifiedResultProjection {
export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultSource): UnifiedResultProjection | undefined {
const { observation, importFile, scanRun, assessment, project } = source;
const subjectIdentity = observation.subject.identity ?? observation.subject.label;
const normalized = observation.normalizedValue ?? observation.originalValue;
const disclosure = discloseSupplementalObservation(observation);
if (!disclosure) return undefined;
return {
resultProjectionId: `environment-observation:${observation.id}`,
sourceKind: 'environment-observation',
@ -602,19 +781,14 @@ export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultS
processingStatus: processingReadiness(importFile.processedWith.profileId, importFile.processedWith),
reprocessingAvailable: importFile.sourceArtifact.kind !== 'not-retained',
importArtifactId: importFile.id,
sourceViewId: observation.sectionKey,
sourceLocator: observation.source,
environmentProperty: observation.property,
environmentSubjectType: observation.subject.type,
environmentSubjectIdentity: subjectIdentity,
environmentValueType: observation.valueType,
environmentState: observation.state,
environmentOriginalValue: observation.originalValue,
environmentNormalizedValue: normalized,
environmentSectionKey: observation.sectionKey,
environmentAttributes: observation.attributes,
title: observation.label,
affectedAsset: subjectIdentity,
environmentProperty: disclosure.property,
environmentSubjectType: disclosure.subjectType,
environmentValueType: disclosure.valueType,
environmentState: disclosure.state,
environmentNormalizedValue: disclosure.value,
supplementalDisclosurePolicyVersion: disclosure.policyVersion,
supplementalDisclosureClass: disclosure.disclosureClass,
title: disclosure.title,
severityOriginal: null,
severityNormalized: 'info',
outcomeOriginal: observation.state,
@ -627,13 +801,12 @@ export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultS
mappingConfidence: 1,
completenessState: observation.state,
reviewStatus: 'unreviewed',
sourceObservedAt: observation.observedAt,
importedAt: importFile.importedAt,
createdAt: importFile.importedAt,
categories: [{
namespace: 'environment-subject',
categoryCode: observation.subject.type,
categoryLabel: observation.subject.type,
categoryCode: disclosure.subjectType,
categoryLabel: disclosure.subjectType,
categoryType: 'canonical',
isPrimary: true,
sourceTool: observation.tool.id,
@ -641,8 +814,8 @@ export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultS
sourceProfileVersion: importFile.processedWith.profileVersion
}, {
namespace: 'environment-property',
categoryCode: environmentPropertyGroup(observation.property),
categoryLabel: environmentPropertyGroup(observation.property),
categoryCode: environmentPropertyGroup(disclosure.property),
categoryLabel: environmentPropertyGroup(disclosure.property),
categoryType: 'canonical',
isPrimary: false,
sourceTool: observation.tool.id,
@ -716,6 +889,11 @@ export function filterUnifiedResults(items: UnifiedResultProjection[], query: Un
policyIdentityKey: item.policyIdentityKey,
policyValues: item.policyValues,
policyDiagnosticCodes: item.policyDiagnosticCodes,
policyBaselineLabel: item.policyBaselineLabel,
policyBaselineValue: item.policyBaselineValue,
policyActualValue: item.policyActualValue,
policyComparisonRelation: item.policyComparisonRelation,
policyCustomerState: item.policyCustomerState,
categories: item.categories.map(category => category.categoryLabel)
}));
if (!text.includes(search)) return false;

View File

@ -25,6 +25,10 @@ function registryDataValue(data: GpoRegistryPolicyData | undefined): PolicyCompa
export function policyComparableValue(value: GpoPolicyValue | undefined): PolicyComparableValue | undefined {
if (!value) return undefined;
if (value.kind === 'registry-policy') return registryDataValue(value.data);
// Security-template registry values use their INF type code plus raw value.
// Retain the type in the comparable representation so equal text encoded as
// different registry types is not treated as the same recommendation.
if (value.kind === 'registry') return { kind: 'text', value: `${value.typeCode},${value.rawData}` };
if (value.kind === 'scalar') return { kind: 'text', value: value.raw };
if (value.kind === 'principal-set') return { kind: 'string-set', values: sortedUnique(value.principals) };
if (value.kind === 'audit-flags') return { kind: 'audit-flags', success: value.success, failure: value.failure };

View File

@ -0,0 +1,126 @@
import type { UnifiedResultProjection } from '../domain/unified-result';
import type { PolicyEvaluationResult, StoredPolicyEvaluation } from '../domain/policy-evaluation';
import type { GpoPolicyValue } from '../domain/gpo-policy';
import { canonicalJson } from '../shared/canonical-json';
import { policyComparableValue } from './policy-comparator';
import { policyComparisonDisposition } from '../domain/policy-comparison-disposition';
export type PolicyWorkStatus = 'Fail' | 'Pass' | 'Conflicts' | 'Duplicates' | 'Not configured';
export type PolicyWorkCounts = Record<PolicyWorkStatus, number>;
export function countPolicyWorkResults(results: PolicyWorkResult[]): PolicyWorkCounts {
const counts: PolicyWorkCounts = { Fail: 0, Pass: 0, Conflicts: 0, Duplicates: 0, 'Not configured': 0 };
for (const result of results) counts[result.status]++;
return counts;
}
export interface PolicyWorkResult {
id: string;
status: PolicyWorkStatus;
title: string;
scope: string;
identity: string;
importedAt: string;
sources: UnifiedResultProjection[];
comparison?: PolicyEvaluationResult;
}
export function policyMisconfigurationImpact(results: PolicyWorkResult[]): { settings: number; backups: number } {
const settings = new Set<string>();
const backups = new Set<string>();
for (const result of results.filter(result => result.status === 'Fail')) {
for (const source of result.sources) {
settings.add(JSON.stringify([source.projectId, source.assessmentId, result.scope, result.identity]));
if (source.policyBackupInstanceId) backups.add(JSON.stringify([source.projectId, source.assessmentId, source.policyBackupInstanceId]));
}
}
return { settings: settings.size, backups: backups.size };
}
const fold = (value: string) => value.replace(/\//g, '\\').toLowerCase();
/** A value signature excludes location/provenance but retains the registry type. */
export function policySettingValueSignature(value: GpoPolicyValue): string | undefined {
const comparable = policyComparableValue(value);
if (!comparable) return undefined;
return canonicalJson({
value: value.kind === 'registry-policy' && value.data?.kind === 'multi-string' ? value.data : comparable,
...(value.kind === 'registry-policy' || value.kind === 'registry' ? { type: value.typeCode } : {})
});
}
function address(source: UnifiedResultProjection): string | undefined {
if (source.policyScope !== 'computer' && source.policyScope !== 'user') return undefined;
if (source.policyDeliveryMechanism === 'group-policy-preference') return undefined;
const values = source.policyValues as GpoPolicyValue[] | undefined;
if (!values?.length) return undefined;
if (source.policyDeliveryMechanism === 'registry-policy') {
const registry = values.filter(value => value.kind === 'registry-policy');
// Conditional writes and delete instructions are not ordinary value assignments.
if (registry.length !== values.length || registry.some(value => value.operation !== 'set-value')) return undefined;
const targets = new Set(registry.map(value => JSON.stringify([fold(value.key), fold(value.valueName ?? '')])));
return targets.size === 1 ? `${source.policyScope}:registry:${[...targets][0]}` : undefined;
}
return source.policyIdentityStatus === 'resolved' && source.policyIdentityKey
? `${source.policyScope}:${source.policyIdentityKey}` : undefined;
}
function comparisonTitle(result: PolicyEvaluationResult): string {
const target = result.identity.target;
return target.valueName || ('section' in target ? `${target.section} · ${target.key}` : target.key);
}
/** Explicit comparison outcomes; absent settings remain separate from misconfiguration. */
export function buildPolicyWorkResults(
observations: UnifiedResultProjection[], evaluations: StoredPolicyEvaluation[]
): PolicyWorkResult[] {
const sources = [...new Map(observations.filter(source => source.sourceKind === 'policy-observation')
.map(source => [source.sourceResultId, source])).values()];
const byId = new Map(sources.map(source => [source.sourceResultId, source]));
const groups = new Map<string, UnifiedResultProjection[]>();
for (const source of sources) {
const target = address(source);
if (!target || source.policyValues?.some(value => !policySettingValueSignature(value as GpoPolicyValue))) continue;
const key = JSON.stringify([source.projectId, source.assessmentId, target]);
const group = groups.get(key) ?? [];
group.push(source);
groups.set(key, group);
}
const output: PolicyWorkResult[] = [];
const conflictedIds = new Set<string>();
for (const [key, group] of groups) {
const signatures = new Set(group.flatMap(source => (source.policyValues as GpoPolicyValue[])
.map(policySettingValueSignature)));
if (signatures.size < 2 && group.length < 2) continue;
const status = signatures.size > 1 ? 'Conflicts' : 'Duplicates';
if (status === 'Conflicts') group.forEach(source => conflictedIds.add(source.sourceResultId));
output.push({ id: `${status}:${key}`, status, title: group[0].title,
scope: group[0].policyScope!, identity: group[0].policyIdentityKey ?? key,
importedAt: group.reduce((latest, source) => source.importedAt > latest ? source.importedAt : latest, ''), sources: group });
}
const seen = new Set<string>();
for (const evaluation of evaluations) for (const comparison of evaluation.results) {
const id = comparison.id ?? JSON.stringify([comparison.baselineAnalysisId, comparison.baselineLabel,
comparison.identity?.id, comparison.observationIds]);
if (seen.has(id)) continue;
seen.add(id);
// Never mix a comparison from another selected timeslot into this evidence set.
if (comparison.observationIds.some(sourceId => !byId.has(sourceId))) continue;
const evidence = comparison.observationIds.map(sourceId => byId.get(sourceId)!);
const hasCustomerConflict = comparison.observationIds.some(sourceId => conflictedIds.has(sourceId));
if (hasCustomerConflict) continue;
const baselineConflict = comparison.diagnosticCodes.includes('policy-evaluation-baseline-consensus-conflict');
const baseline = comparison.comparator.baseline;
const hasBaseline = baseline !== undefined && baseline.kind !== 'none'
&& !(baseline.kind === 'text' && !baseline.value.trim());
let status: PolicyWorkStatus;
if (baselineConflict) status = 'Conflicts';
else if (comparison.baselineState === 'explicit-recommendation' && hasBaseline
&& comparison.customerState === 'configured' && comparison.comparator.actual !== undefined
&& comparison.classification === 'configured_match' && comparison.comparator.relation === 'match') status = 'Pass';
else if (policyComparisonDisposition(comparison) === 'misconfigured') status = 'Fail';
else if (policyComparisonDisposition(comparison) === 'not-configured') status = 'Not configured';
else continue;
output.push({ id, status, title: comparisonTitle(comparison), scope: comparison.identity.scope,
identity: comparison.identity.id, importedAt: evaluation.run.completedAt, sources: evidence, comparison });
}
return output;
}

View File

@ -5,7 +5,7 @@ import type {
} from '../domain/unified-result';
export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl';
export const RESULT_DB_JSONL_SCHEMA_VERSION = '1.0.0';
export const RESULT_DB_JSONL_SCHEMA_VERSION = '1.1.0';
export const RESULT_DB_EXPORT_PAGE_SIZE = 1000;
type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>;
@ -24,6 +24,23 @@ function exportFilters(query: UnifiedResultQuery): Record<string, unknown> {
return Object.fromEntries(definedQueryEntries(query).filter(([key]) => !excluded.has(key)));
}
function resultForExport(result: UnifiedResultProjection): UnifiedResultProjection {
if (result.sourceKind !== 'environment-observation') return result;
if (!result.supplementalDisclosurePolicyVersion || !result.supplementalDisclosureClass) {
throw new Error('An unclassified supplemental result cannot be exported.');
}
const disclosed = { ...result };
delete disclosed.affectedAsset;
delete disclosed.environmentAttributes;
delete disclosed.environmentOriginalValue;
delete disclosed.environmentSectionKey;
delete disclosed.environmentSubjectIdentity;
delete disclosed.sourceLocator;
delete disclosed.sourceObservedAt;
delete disclosed.sourceViewId;
return disclosed;
}
export function buildUnifiedResultJsonl(
query: UnifiedResultQuery,
results: UnifiedResultProjection[],
@ -40,7 +57,7 @@ export function buildUnifiedResultJsonl(
}, ...results.map((result, index) => ({
recordType: 'result',
position: index + 1,
result
result: resultForExport(result)
}))];
return `${lines.map(line => JSON.stringify(line)).join('\n')}\n`;
}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { AssessorHealthResponse } from '../../api/assessor-api-contract';
import { Dialog } from '../../components/Dialog/Dialog';
import { ASSESSOR_PRODUCT_VERSION } from '../../domain/assessor-release';
import { GPO_POLICY_IDENTITY_RESOLVER } from '../../domain/gpo-policy-identity';
import { GPO_POLICY_SNAPSHOT_AGGREGATOR } from '../../domain/gpo-snapshot-aggregation';
import { MICROSOFT_BASELINE_SETTINGS_ANALYZER } from '../../domain/microsoft-security-baseline-analysis';
import {
POLICY_COMPARATOR_CATALOG,
POLICY_EVALUATION_ENGINE
} from '../../domain/policy-evaluation';
import { CURRENT_ASSESSOR_PROCESSING_VERSION } from '../../domain/processing-compatibility';
import assessorLogo from '../../assets/branding/assessor-icon-transparent.png';
const api = new AssessorApiClient();
const ASSESSOR_BUILD_TIME = import.meta.env.VITE_ASSESSOR_BUILD_TIME || 'development';
const ASSESSOR_BUILD_ID = ASSESSOR_BUILD_TIME === 'development'
? `${ASSESSOR_PRODUCT_VERSION}-development`
: `${ASSESSOR_PRODUCT_VERSION}+${ASSESSOR_BUILD_TIME.replace(/\D/g, '').slice(0, 14)}`;
function buildTimeLabel(): string {
if (ASSESSOR_BUILD_TIME === 'development') return 'Development';
return new Date(ASSESSOR_BUILD_TIME).toLocaleString();
}
function Detail({ label, value }: { label: string; value: string }) {
return <div className="about-detail"><dt>{label}</dt><dd>{value}</dd></div>;
}
export function AboutDialog({ onClose }: { onClose(): void }) {
const [health, setHealth] = useState<AssessorHealthResponse>();
useEffect(() => {
let active = true;
void api.health().then(value => { if (active) setHealth(value); }).catch(() => undefined);
return () => { active = false; };
}, []);
const backend = health
? `${health.productVersion}${health.releaseId ? ` · ${health.releaseId.slice(0, 12)}` : ''}`
: 'Unavailable';
return <Dialog title="About ASSESSOR" onClose={onClose}>
<div className="about-sections">
<div className="about-brand" aria-hidden="true"><img src={assessorLogo} alt=""/></div>
<section>
<h3>Product</h3>
<dl>
<Detail label="Version" value={ASSESSOR_PRODUCT_VERSION}/>
<Detail label="Build" value={ASSESSOR_BUILD_ID}/>
<Detail label="Built" value={buildTimeLabel()}/>
<Detail label="Backend" value={backend}/>
</dl>
</section>
<section>
<h3>Processing</h3>
<dl>
<Detail label="Import contract" value={CURRENT_ASSESSOR_PROCESSING_VERSION}/>
<Detail label="GPO evaluation" value={POLICY_EVALUATION_ENGINE.version}/>
<Detail label="GPO comparison" value={POLICY_COMPARATOR_CATALOG.version}/>
<Detail label="Baseline analysis" value={MICROSOFT_BASELINE_SETTINGS_ANALYZER.version}/>
<Detail label="Snapshot aggregation" value={GPO_POLICY_SNAPSHOT_AGGREGATOR.version}/>
<Detail label="Policy identity" value={GPO_POLICY_IDENTITY_RESOLVER.version}/>
</dl>
</section>
<footer className="about-credits">Credits · Michael Lukas · Binh Karl</footer>
</div>
</Dialog>;
}

View File

@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useState, type CSSProperties, type ReactNode
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
import type { FindingRecord } from '../../domain/finding';
@ -598,11 +599,12 @@ export function DashboardLargeCard({ title, meta, summary, className = '', detai
const [expanded, setExpanded] = useState(initiallyExpanded);
const detailsId = useId();
return <section className={`client-info-card client-info-detail-card ${expanded ? 'is-expanded' : 'is-collapsed'} ${className}`.trim()}>
<header>
<header onClick={() => setExpanded(value => !value)}>
<span className="client-info-card-heading"><h2>{title}</h2>{summary && <span className="client-info-card-primary">— {summary.label} {typeof summary.value === 'number' ? summary.value.toLocaleString('en-US') : summary.value}{summary.detail ? ` ${summary.detail}` : ''}</span>}{meta && <span className="client-info-card-meta">{meta}</span>}</span>
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'} /></button>
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</header>
{expanded && <div className="client-info-card-content" id={detailsId}>
<div className="client-info-card-details">{children}</div>
@ -615,11 +617,12 @@ export function DashboardDonutCard({ title, meta, initiallyExpanded = true, chil
const [expanded, setExpanded] = useState(initiallyExpanded);
const detailsId = useId();
return <section className={`client-info-card client-info-donut-card ${expanded ? 'is-expanded' : 'is-collapsed'}`}>
<header>
<header onClick={() => setExpanded(value => !value)}>
<span className="client-info-card-heading"><h2>{title}</h2><span className="client-info-card-primary">— {meta}</span></span>
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'} /></button>
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</header>
{expanded && <div className="client-info-card-content" id={detailsId}><div className="client-info-card-details">{children}</div></div>}
</section>;
@ -634,7 +637,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
);
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
if (!dataset || !activeSheet) return <EmptyWorkspace />;
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />;
const total = activeSheet.analysis.records.length;
const statusFacet: DynamicFacet | undefined = facets.find(facet => facet.source === 'profile-status');

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useId, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { DbStructureResponse, DbStructureTable } from '../../domain/db-structure';
import { TriangleIcon } from '../../components/TriangleIcon';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
const assessorApi = new AssessorApiClient();
@ -11,22 +12,73 @@ interface DbStructurePanelProps {
}
const LAYER_ORDER = ['import', 'mapping', 'canonical', 'result', 'governance', 'core', 'other'] as const;
const LAYER_LABEL: Record<(typeof LAYER_ORDER)[number], string> = {
import: 'Layer 1 · Import / Ingestion',
mapping: 'Layer 2 · Mapping / Normalization',
canonical: 'Layer 3 · Canonical Model',
result: 'Layer 4 · Result Layer',
governance: 'Governance & Quality',
core: 'Core Management',
other: 'Other'
type LayerId = (typeof LAYER_ORDER)[number];
const LAYER_INFO: Record<LayerId, { kicker: string; label: string; description: string }> = {
import: { kicker: 'Layer 1', label: 'Import & ingestion', description: 'Source files, ingestion jobs and immutable import evidence.' },
mapping: { kicker: 'Layer 2', label: 'Mapping & normalization', description: 'Recognition, mapping and normalization contracts between source and domain data.' },
canonical: { kicker: 'Layer 3', label: 'Canonical model', description: 'Stable assessments, findings and environment context independent of source tools.' },
result: { kicker: 'Layer 4', label: 'Result layer', description: 'Read models and result projections used by Work, filters and exports.' },
governance: { kicker: 'Supporting layer', label: 'Governance & quality', description: 'Reviews, validation, processing compatibility and quality controls.' },
core: { kicker: 'Foundation', label: 'Core management', description: 'Projects, scan runs and shared lifecycle state.' },
other: { kicker: 'Supporting layer', label: 'Other', description: 'Additional relational structures outside the primary processing layers.' }
};
function groupByLayer(tables: DbStructureTable[]): Array<{ layer: string; tables: DbStructureTable[] }> {
function groupByLayer(tables: DbStructureTable[]): Array<{ layer: LayerId; tables: DbStructureTable[] }> {
return LAYER_ORDER
.map(layer => ({ layer, tables: tables.filter(table => table.layer === layer) }))
.filter(group => group.tables.length > 0);
}
export function summarizeDbStructure(tables: DbStructureTable[]) {
const countedTables = tables.filter(table => table.approximateRowCount !== null);
return {
tables: tables.length,
columns: tables.reduce((total, table) => total + table.columns.length, 0),
relationships: tables.reduce((total, table) => total + table.foreignKeys.length, 0),
approximateRows: countedTables.reduce((total, table) => total + (table.approximateRowCount ?? 0), 0),
countedTables: countedTables.length
};
}
function DbStructureTableCard({ table, isOpen, onToggle }: { table: DbStructureTable; isOpen: boolean; onToggle: () => void }) {
const detailsId = useId();
return <article className={`db-structure-card${isOpen ? ' is-expanded' : ''}`}>
<button className="db-structure-card-header" type="button" onClick={onToggle} aria-expanded={isOpen} aria-controls={detailsId}>
<span className="db-structure-card-copy">
<span className="db-structure-table-kind">Table</span>
<strong>{table.name}</strong>
<span className="db-structure-card-facts">
<span>{table.columns.length.toLocaleString('en-US')} columns</span>
<span>{table.foreignKeys.length.toLocaleString('en-US')} relations</span>
{table.approximateRowCount !== null && <span>~{table.approximateRowCount.toLocaleString('en-US')} rows</span>}
</span>
</span>
<span className="db-structure-card-toggle" aria-hidden="true"><TriangleIcon direction={isOpen ? 'down' : 'right'} /></span>
</button>
{isOpen && <div className="db-structure-card-body" id={detailsId}>
<section className="db-structure-card-section" aria-label={`Columns in ${table.name}`}>
<div className="db-structure-detail-heading"><h4>Columns</h4><span>{table.columns.length.toLocaleString('en-US')}</span></div>
<div className="db-structure-column-head" aria-hidden="true"><span>Column</span><span>Type</span><span>Constraint</span></div>
<ul className="db-structure-columns">
{table.columns.map(column => <li key={column.name}>
<strong>{column.name}</strong>
<span>{column.dataType}</span>
<small>{column.nullable ? 'Nullable' : 'Required'}</small>
</li>)}
</ul>
</section>
<section className="db-structure-card-section db-structure-relations" aria-label={`Relationships from ${table.name}`}>
<div className="db-structure-detail-heading"><h4>Relationships</h4><span>{table.foreignKeys.length.toLocaleString('en-US')}</span></div>
{table.foreignKeys.length > 0 ? <ul className="db-structure-fks">
{table.foreignKeys.map(fk => <li key={`${fk.column}-${fk.referencedTable}-${fk.referencedColumn}`}>
<strong>{fk.column}</strong><span aria-hidden="true">→</span><span>{fk.referencedTable}.{fk.referencedColumn}</span>
</li>)}
</ul> : <p>No outgoing relationships.</p>}
</section>
</div>}
</article>;
}
export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
const [structure, setStructure] = useState<DbStructureResponse | null>(null);
const [loading, setLoading] = useState(true);
@ -59,35 +111,48 @@ export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
}
const groups = structure ? groupByLayer(structure.tables) : [];
const metrics = structure ? summarizeDbStructure(structure.tables) : undefined;
const allExpanded = Boolean(structure?.tables.length) && structure!.tables.every(table => expanded[table.name]);
const setAllExpanded = (open: boolean) => {
if (!structure) return;
setExpanded(Object.fromEntries(structure.tables.map(table => [table.name, open])));
};
return <div className="datahub-page db-structure-page">
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
{loading && !structure ? <div className="datahub-loading"><i/><span>Loading database structure …</span></div> : structure && <>
<div className="page-actions-row">
<p className="db-structure-meta">As of: {new Date(structure.generatedAt).toLocaleString('en-US')} · Persistence: {structure.persistence === 'postgresql' ? 'PostgreSQL' : 'Memory mode (no live schema)'} · {structure.tables.length.toLocaleString('en-US')} tables</p>
<button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
{loading && !structure ? <PanelLoadingState message="Loading database structure …"/> : structure && <>
<header className="db-structure-intro">
<div className="db-structure-intro-copy">
<span className="db-structure-eyebrow">Database dictionary</span>
<h2>Relational schema</h2>
<p>Explore how imported evidence moves through normalization into canonical and result data. Open any table to inspect its columns, constraints and relationships.</p>
</div>
<div className="db-structure-intro-actions">
<span className={`db-structure-persistence is-${structure.persistence}`}><i aria-hidden="true" />{structure.persistence === 'postgresql' ? 'PostgreSQL live schema' : 'Memory mode'}</span>
<button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh schema'}</button>
</div>
</header>
{metrics && <section className="db-structure-summary" aria-label="Database structure summary">
<article><span>Tables</span><strong>{metrics.tables.toLocaleString('en-US')}</strong><small>Across {groups.length.toLocaleString('en-US')} active layers</small></article>
<article><span>Columns</span><strong>{metrics.columns.toLocaleString('en-US')}</strong><small>Typed schema fields</small></article>
<article><span>Relationships</span><strong>{metrics.relationships.toLocaleString('en-US')}</strong><small>Declared foreign keys</small></article>
<article><span>Approximate rows</span><strong>{metrics.countedTables > 0 ? metrics.approximateRows.toLocaleString('en-US') : '—'}</strong><small>{metrics.countedTables > 0 ? `Reported by ${metrics.countedTables.toLocaleString('en-US')} tables` : 'No estimates reported'}</small></article>
</section>}
<div className="db-structure-toolbar">
<p className="db-structure-meta">Schema snapshot <time dateTime={structure.generatedAt}>{new Date(structure.generatedAt).toLocaleString('en-US')}</time></p>
{structure.tables.length > 0 && <button className="btn" type="button" onClick={() => setAllExpanded(!allExpanded)}>{allExpanded ? 'Collapse all' : 'Expand all'}</button>}
</div>
{structure.tables.length === 0 ? <div className="datahub-empty"><h3>No tables found</h3><p>The current persistence mode has no relational schema available to introspect.</p></div> : <div className="db-structure-layers">
{groups.map(group => <section className="db-structure-layer" key={group.layer}>
<div className="datahub-section-heading"><div><span>Layer</span><h3>{LAYER_LABEL[group.layer as (typeof LAYER_ORDER)[number]]}</h3></div><strong>{group.tables.length}</strong></div>
<header className="db-structure-layer-header">
<div><span>{LAYER_INFO[group.layer].kicker}</span><h3>{LAYER_INFO[group.layer].label}</h3><p>{LAYER_INFO[group.layer].description}</p></div>
<strong>{group.tables.length.toLocaleString('en-US')} {group.tables.length === 1 ? 'table' : 'tables'}</strong>
</header>
<div className="db-structure-grid">
{group.tables.map(table => {
const key = table.name;
const isOpen = expanded[key] ?? false;
return <article className="db-structure-card" key={key}>
<button className="db-structure-card-header" type="button" onClick={() => setExpanded(current => ({ ...current, [key]: !isOpen }))} aria-expanded={isOpen}>
<span className="db-structure-card-title"><strong>{table.name}</strong><small>{table.columns.length} columns{table.approximateRowCount !== null ? ` · ~${table.approximateRowCount.toLocaleString('en-US')} rows` : ''}</small></span>
<span className="db-structure-card-toggle" aria-hidden="true"><TriangleIcon direction={isOpen ? 'down' : 'right'} /></span>
</button>
{isOpen && <div className="db-structure-card-body">
<ul className="db-structure-columns">
{table.columns.map(column => <li key={column.name}><code>{column.name}</code><span>{column.dataType}{column.nullable ? '' : ' · NOT NULL'}</span></li>)}
</ul>
{table.foreignKeys.length > 0 && <ul className="db-structure-fks">
{table.foreignKeys.map(fk => <li key={`${fk.column}-${fk.referencedTable}`}>{fk.column} → {fk.referencedTable}.{fk.referencedColumn}</li>)}
</ul>}
</div>}
</article>;
return <DbStructureTableCard key={key} table={table} isOpen={isOpen} onToggle={() => setExpanded(current => ({ ...current, [key]: !isOpen }))} />;
})}
</div>
</section>)}
@ -95,4 +160,3 @@ export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
</>}
</div>;
}

View File

@ -5,6 +5,7 @@ import type { Review } from '../../domain/review';
import { defaultReview } from '../../domain/review';
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText';
import { normalizeNumericValue } from '../../shared/text';
import { sourceFileLabel } from '../../shared/source-file-label';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
@ -14,6 +15,10 @@ import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation';
import { policyFindingCategories } from '../../profiles/group-policy/categories';
import { findingWebSearch } from '../../profiles/public-search/search';
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
interface Props {
record: FindingRecord;
@ -48,7 +53,7 @@ const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLo
*/
const isAdditionalOnlyField = (label: string) => {
const normalized = normalizeLabel(label);
return /^(?:short\s*name|si version|target|runtime|points scale|rule id)$/.test(normalized)
return /^(?:short\s*name|si version|target|runtime|points scale|rule id|policy type|policy section|description source)$/.test(normalized)
|| /^(?:quelle|source):? /.test(normalized)
|| /^anssi \d+$/.test(normalized);
};
@ -192,6 +197,14 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
.forEach(field => hiddenFieldIndexes.add(field.index));
}
const statusField = resultStatusFieldFor(record, toolId);
const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0;
if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
const policyComparisonFields = toolId === 'group-policy' && !hasPolicyEvidenceResults ? record.fields.filter(field => {
const label = normalizeLabel(field.label);
return field.value && (label === 'configured value' || label === 'baseline value');
}) : [];
policyComparisonFields.forEach(field => hiddenFieldIndexes.add(field.index));
const badges = record.fields.filter(field => {
if (!field.value) return false;
const label = normalizeLabel(field.label);
@ -201,6 +214,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label);
if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (normalized === 'result message') return 0;
if (/^number of results?$/.test(normalized)) return 1;
if (normalized === 'description') return 2;
@ -246,7 +260,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const checkTitleIndex = summary.findIndex(field => normalizeLabel(field.label) === 'check title');
const checkTitle = checkTitleIndex >= 0 ? summary.splice(checkTitleIndex, 1)[0] : undefined;
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields };
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields };
}
export function FindingReader({ record, title, review: storedReview, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onResults, resultsOpen, resultColor }: Props) {
@ -258,7 +272,10 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
const severity = normalizeSeverity(severityField?.value);
const hasStructuredResults = linkedSheets.length > 0;
const resolvedToolId = toolIdForRecord(record, toolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
// Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten
// sonst ihren gesamten Inhalt ein zweites Mal als "Referenz" dort stehen.
@ -290,10 +307,15 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
const value = isSeverity ? field.value : field.label + ': ' + field.value + scale;
return <span className={className} key={field.label}>{highlightText(value, search)}</span>;
})}
{policyFindingCategories(record, resolvedToolId).map(category => <span className="status-badge" data-derived-category="gpo" key={category}>{highlightText(category, search)}</span>)}
{resolvedToolId === 'group-policy' && record.fields.filter(field => normalizeLabel(field.label) === 'scope' && field.value.trim()).slice(0, 1).map(field => (
<span className="status-badge" data-policy-scope key="policy-scope">Scope: {highlightText(field.value, search)}</span>
))}
</div>
{mitre.length > 0 && <div className="mitre-line">{mitre.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
</div>
<div className="reader-heading-actions">
{webSearch && <a className="btn" href={webSearch.url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" data-search-specificity={webSearch.specificity} title={`${webSearch.specificity === 'topic' ? 'Topic search — ' : ''}${webSearch.query}`}>AI search</a>}
<span className="bookmark-label">Bookmark</span>
<button className="bookmark-btn" type="button" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
{/* Gleiche Kennzeichnung wie auf den Kacheln: ist das Ergebnisfenster
@ -308,11 +330,22 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
<label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label>
</section>
<section className="reader-summary">
<h3>{highlightText(checkTitle?.value || 'Finding', search)}</h3>
{summary.map(field => <div key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4><RichTextValue value={field.value} search={search} onResults={onResults} /></div>)}
<h3>{highlightText(policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding'), search)}</h3>
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={comparisonOutcome}>
{policyComparisonFields.map(field => {
const expected = normalizeLabel(field.label) === 'baseline value';
return <section className="policy-value-block" data-value={expected ? 'expected' : 'configured'} key={`${field.index}-${field.label}`}>
<h4>{expected ? 'Expected baseline value' : 'Configured customer value'}</h4>
<p className="policy-guidance-text"><PathText value={formatPolicyComparisonValue(field.value)} search={search} /></p>
</section>;
})}
</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))
? <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}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
<RichTextValue value={field.value} search={search} onResults={onResults} />
<RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />
</details>)}
</section>
<section className="review-grid">
@ -343,7 +376,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
{remainingFields.length > 0 && (
<details className="all-fields">
<summary><TriangleIcon direction="right" />Additional Information</summary>
<table><tbody>{remainingFields.map(field => <tr key={`${field.index}-${field.label}`}><th>{highlightText(field.label, search)}</th><td><RichTextValue value={field.value} search={search} onResults={onResults} /></td></tr>)}</tbody></table>
<table><tbody>{remainingFields.map(field => <tr key={`${field.index}-${field.label}`}><th>{highlightText(field.label, search)}</th><td><RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} /></td></tr>)}</tbody></table>
</details>
)}
</article>

View File

@ -1,6 +1,7 @@
import { useApp } from '../../app/AppProvider';
import type { ImportPreviewEntry } from '../../domain/import-workflow';
import { Dialog } from '../../components/Dialog/Dialog';
import { OperationProgress } from '../../components/OperationProgress/OperationProgress';
const statusLabels: Record<ImportPreviewEntry['status'], string> = {
ready: 'Ready',
@ -105,8 +106,8 @@ export function ImportWorkflowDialog() {
}
return (
<Dialog title={busy ? (importWorkflow.phase === 'preparing' ? 'Pre-checking files' : 'Finishing import') : 'Review and approve import'} onClose={() => { if (!busy) closeImportWorkflow(); }}>
{busy && <div className="import-progress" role="status"><span className="import-spinner" aria-hidden="true"><i/><i/><i/><i/></span>Files are being processed locally. Please wait…</div>}
<Dialog title={busy ? 'Processing' : 'Review and approve import'} dismissible={!busy} onClose={closeImportWorkflow}>
{busy && <OperationProgress label={importWorkflow.phase === 'preparing' ? 'Preparing files' : 'Importing files'}/>}
{!busy && (
<>
<p className="import-dialog-intro">{reimport

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import type { DatasetSheet } from '../../domain/dataset';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText';
import { ResizableResultTable } from '../../components/ResizableResultTable/ResizableResultTable';
import type { ResultSection } from './result-message';
import type { ResultSheetRowTone } from './result-message';
@ -91,14 +92,14 @@ export function LinkedResults({ sheets, search = '', heading, sections = [], sho
<section className="result-block" key={sheet.id}>
{displayTitle && displayTitle !== previousTitle && <h4 className="result-block__title">{displayTitle}</h4>}
{displaySubtitle && <h5 className="result-block__subtitle">{displaySubtitle}</h5>}
{sheet.resultPreamble && <p className="result-block__preamble">{highlighted(sheet.resultPreamble, search)}</p>}
{sheet.resultPreamble && <p className="result-block__preamble"><PathText value={sheet.resultPreamble} search={search} /></p>}
<div className="rich-text-table-wrap">
<ResizableResultTable enabled={resizableTables} headers={headers}>
<ResizableResultTable enabled={resizableTables} headers={headers} className={`rich-text-table${sheet.resultEmphasisColumns ? ' result-evidence-table' : ''}`}>
{sheet.analysis.records.map(record => {
const tone = rowTone?.(record);
return <tr key={record.id} className={tone ? `rich-text-table-row--${tone}` : record.highlighted || rowHighlighted?.(record) ? 'rich-text-table-row--highlight' : undefined}>{headers.map(header => {
const value = record.fields.find(field => field.label === header)?.value ?? '';
return <td key={`${record.id}-${header}`}>{highlighted(value, search)}</td>;
return <td key={`${record.id}-${header}`} className={sheet.resultEmphasisColumns?.includes(header) ? 'result-evidence-difference' : undefined}><PathText value={value} search={search} label={header} /></td>;
})}</tr>;
})}
</ResizableResultTable>

View File

@ -19,6 +19,7 @@ import type { FindingRecord } from '../../domain/finding';
import { makeSheet } from '../../importers/sheet-factory';
import { extractMarkdownTables } from '../../shared/markdown';
import { clean } from '../../shared/text';
import { policyEvidenceResultSheets } from '../../profiles/group-policy/conflict-presentation';
/** Felder, in denen Werkzeuge ihre Ergebnisdetails ablegen. */
const RESULT_DETAIL_LABEL =
@ -78,7 +79,7 @@ const inlineTableCache = new WeakMap<FindingRecord, DatasetSheet[]>();
export function inlineResultTableSheets(record: FindingRecord): DatasetSheet[] {
const cached = inlineTableCache.get(record);
if (cached) return cached;
const sheets: DatasetSheet[] = [];
const sheets: DatasetSheet[] = [...policyEvidenceResultSheets(record)];
for (const field of record.fields) {
if (!field.value || !RESULT_DETAIL_LABEL.test(clean(field.label))) continue;
const markdownTables = extractMarkdownTables(field.value);

View File

@ -14,6 +14,7 @@ export interface ResultSection {
/** Profilgesteuerte Sichtbarkeit fachlicher Titel verknuepfter Belegtabellen. */
export function showResultSheetTitlesFor(toolId?: string): boolean {
if (toolId === 'group-policy') return true;
if (!toolId) return false;
return toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))
?.presentation.showResultSheetTitles ?? false;

View File

@ -3,11 +3,13 @@ import type { Assessment, AssessmentProject, CanonicalAssessmentModel, ImportFil
import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { Dialog } from '../../components/Dialog/Dialog';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import { FileImport } from '../file-import/FileImport';
import { stableAssessmentId, stableProjectId } from '../../shared/identifiers';
import { groupProjectImportFiles, type ScanImportGroup } from './datahub-import-groups';
import { GpoFolderImport } from './GpoFolderImport';
import { PolicyAnalysisPanel } from './PolicyAnalysisPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
@ -17,6 +19,7 @@ import { comparableTimeslotSources, summarizeTimeslotResults, type DatahubTimesl
import { datahubScanReadiness, isDatahubScanViewable, type DatahubScanReadiness } from './datahub-scan-readiness';
import { summarizeScanProcessingReadiness } from './datahub-processing-readiness';
import { compareReimportSourceHashes } from '../../domain/reimport-source';
import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness';
const assessorApi = new AssessorApiClient();
const SHOW_MITRE_MATRIX_LIBRARY = false;
@ -25,7 +28,7 @@ type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface DataHubProps {
activeProjectId?: string;
backendStatus: BackendStatus;
onProjectSelected(project?: AssessmentProject, scanRunIds?: string[]): void;
onProjectSelected(project?: AssessmentProject, scanRunIds?: string[], assessmentIds?: string[]): void;
onProjectDataChanged?(projectId: string): void | Promise<void>;
onDetailViewChanged?(isDetailView: boolean, clientName?: string): void;
}
@ -47,7 +50,7 @@ const gpoStateLabels: Record<GpoBackupStatusRecord['state'], string> = {
queued: 'Queued',
running: 'Processing',
succeeded: 'Ready',
partial: 'Partial',
partial: 'Imported',
'dead-letter': 'Failed',
'recovery-required': 'Recovery pending'
};
@ -100,9 +103,12 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [masterDataEditing, setMasterDataEditing] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AssessmentProject | null>(null);
const [scanDeleteTarget, setScanDeleteTarget] = useState<ScanImportGroup | null>(null);
const [gpoDeleteTarget, setGpoDeleteTarget] = useState<GpoBackupStatusRecord | null>(null);
const [timeslotDeleteTarget, setTimeslotDeleteTarget] = useState<ScanImportGroup | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [operationProgress, setOperationProgress] = useState<string>();
const [processingGpoJob, setProcessingGpoJob] = useState<{ projectId: string; artifactId: string; jobId: string }>();
const [error, setError] = useState<string | null>(null);
const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null);
const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix());
@ -118,6 +124,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
const [timeslotDate, setTimeslotDate] = useState('');
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({});
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
@ -132,6 +139,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
if (!file) return;
setMitreError(null);
setMitreFeedback(null);
setOperationProgress('Importing MITRE Matrix');
try {
const text = await file.text();
const parsed = JSON.parse(text);
@ -143,6 +151,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
} catch (err) {
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
} finally {
setOperationProgress(undefined);
event.target.value = '';
}
};
@ -163,6 +172,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
return;
}
setLoading(true);
setProjectOpenReadiness({});
setError(null);
try {
projectModelsCache.current.clear();
@ -173,12 +183,15 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
assessorApi.listProjectAssessmentHistory(project.id),
assessorApi.listProjectGpoBackupStatus(project.id)
]);
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records };
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), project.id,
groupProjectImportFiles(files, history.assessments, history.scanRuns, gpoStatus.records));
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records, openReadiness };
}));
setProjects(list);
setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files])));
setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history])));
setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords])));
setProjectOpenReadiness(Object.fromEntries(data.map(item => [item.projectId, item.openReadiness])));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Projects could not be loaded.');
} finally {
@ -187,6 +200,45 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
}, [backendStatus]);
useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => {
if (backendStatus !== 'connected' || !projects.length) return;
let cancelled = false;
let refreshing = false;
const timer = globalThis.setInterval(async () => {
if (refreshing) return;
refreshing = true;
try {
const entries = await Promise.all(projects.map(async project => {
const response = await assessorApi.listProjectGpoBackupStatus(project.id);
const history = projectHistory[project.id];
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), project.id,
groupProjectImportFiles(projectData[project.id] ?? [], history?.assessments, history?.scanRuns, response.records));
return { projectId: project.id, records: response.records, openReadiness };
}));
if (!cancelled) {
setProjectGpoData(Object.fromEntries(entries.map(entry => [entry.projectId, entry.records])));
setProjectOpenReadiness(Object.fromEntries(entries.map(entry => [entry.projectId, entry.openReadiness])));
}
} catch {
if (!cancelled) {
setProjectOpenReadiness({});
setError('GPO processing status could not be refreshed.');
}
} finally {
refreshing = false;
}
}, 5_000);
return () => { cancelled = true; globalThis.clearInterval(timer); };
}, [backendStatus, projects, projectData, projectHistory]);
useEffect(() => {
if (!processingGpoJob) return;
const record = projectGpoData[processingGpoJob.projectId]?.find(item =>
item.artifactId === processingGpoJob.artifactId && item.jobId === processingGpoJob.jobId);
if (record && !['queued', 'running'].includes(record.state)) {
setProcessingGpoJob(undefined);
setOperationProgress(undefined);
}
}, [processingGpoJob, projectGpoData]);
useEffect(() => {
const refresh = () => { void loadProjects(); };
globalThis.addEventListener('assessor:datahub-changed', refresh);
@ -313,7 +365,8 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const latestTimeslot = groups[0];
const files = projectData[project.id] ?? [];
const affectedTimeslotIds = groups
.filter(group => summarizeScanProcessingReadiness(files.filter(file => group.scanRunIds.includes(file.scanRunId))).state !== 'ready')
.filter(group => summarizeScanProcessingReadiness(files.filter(file => group.scanRunIds.includes(file.scanRunId))).state !== 'ready'
|| group.gpoBackups.some(record => record.state === 'dead-letter' || record.state === 'recovery-required'))
.map(group => group.id);
setError(null);
setSaveFeedback(null);
@ -354,9 +407,13 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const openSelectedTimeslots = () => {
if (!form?.project || !selectedTimeslots.length) return;
const runIds = selectedTimeslots.length === 1
? selectedTimeslots[0].scanRunIds.filter(runId => !excludedScanRunIds.includes(runId))
? selectedTimeslots[0].scanRunIds.filter(runId => !excludedScanRunIds.includes(runId) && isDatahubScanViewable(scanReadiness[runId]))
: selectedSources.runIds;
if (!runIds.length) {
if (selectedTimeslots.length === 1 && projectOpenReadiness[form.project.id]?.[selectedTimeslots[0].id]?.hasPolicyResults) {
onProjectSelected(form.project, [], selectedTimeslots[0].assessmentIds);
return;
}
setError('The selected timeslots do not contain a common comparable scanner source.');
return;
}
@ -531,30 +588,37 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const projectId = form?.project?.id;
if (!projectId) return;
setBusy(true);
setOperationProgress('Starting GPO processing');
setError(null);
setSaveFeedback(null);
try {
await assessorApi.retryGpoBackup(projectId, record.artifactId);
const result = await assessorApi.retryGpoBackup(projectId, record.artifactId);
setProcessingGpoJob({ projectId, artifactId: result.artifactId, jobId: result.jobId });
setOperationProgress('Processing GPO backup');
await loadProjects();
setSaveFeedback({ tone: 'success', message: 'The GPO analysis job was requeued.' });
} catch (reason) {
setOperationProgress(undefined);
setError(reason instanceof Error ? reason.message : 'The GPO analysis job could not be restarted.');
} finally {
setBusy(false);
}
};
const deleteFailedGpoBackup = async (record: GpoBackupStatusRecord) => {
const deleteGpoBackup = async () => {
const projectId = form?.project?.id;
if (!projectId || !globalThis.confirm(`Permanently delete the failed GPO import "${record.fileName}" from ASSESSOR? The source remains unchanged.`)) return;
if (!projectId || !gpoDeleteTarget) return;
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
await assessorApi.deleteFailedGpoBackup(projectId, record.artifactId);
await assessorApi.deleteGpoBackup(projectId, gpoDeleteTarget.artifactId);
setGpoDeleteTarget(null);
await loadProjects();
setSaveFeedback({ tone: 'success', message: 'The failed GPO import was deleted from ASSESSOR.' });
if (activeProjectId === projectId) await onProjectDataChanged?.(projectId);
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
setSaveFeedback({ tone: 'success', message: 'GPO backup and its derived records were deleted from the database.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The failed GPO import could not be deleted.');
setError(reason instanceof Error ? reason.message : 'The GPO backup could not be deleted.');
} finally {
setBusy(false);
}
@ -573,14 +637,17 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
? selectedTimeslots[0].scanRunIds.filter(runId => !excludedScanRunIds.includes(runId) && isDatahubScanViewable(scanReadiness[runId]))
: [];
const openDisabled = !selectedTimeslots.length
|| (selectedTimeslots.length === 1 ? !singleTimeslotRunIds.length : !selectedSources.runIds.length);
|| (selectedTimeslots.length === 1
? !projectOpenReadiness[form?.project?.id ?? '']?.[selectedTimeslots[0].id]?.canOpen
|| (!singleTimeslotRunIds.length && !projectOpenReadiness[form?.project?.id ?? '']?.[selectedTimeslots[0].id]?.hasPolicyResults)
: !selectedSources.runIds.length);
const filesSection = editedProject && <section className="tenant-timeslots" aria-label="Timeslots">
<div className="tenant-timeslots-header">
<h2 className="tenant-timeslots-heading">Timeslots</h2>
<button className="btn tenant-timeslot-add" type="button" aria-label="Create timeslot" title="Create Timeslot" onClick={() => { setTimeslotDate(new Date().toLocaleDateString('sv-SE')); setTimeslotDialogOpen(true); }}><span aria-hidden="true">+</span> Timeslot</button>
</div>
<div className="tenant-timeslot-list">
{loading ? <div className="datahub-loading"><i/><span>Timeslots are loading …</span></div> : editedGroups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No timeslots yet</h3><p>Create the first dated assessment timeslot.</p></div> : editedGroups.map(group => {
{loading ? <PanelLoadingState message="Timeslots are loading …" compact/> : editedGroups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No timeslots yet</h3><p>Create the first dated assessment timeslot.</p></div> : editedGroups.map(group => {
const reports = editedReports.filter(report => group.scanRunIds.includes(report.scanRunId));
const processingByRun = new Map(reports.map(report => [report.scanRunId, summarizeScanProcessingReadiness(report.files)]));
const affectedReports = reports.filter(report => processingByRun.get(report.scanRunId)?.state !== 'ready');
@ -613,10 +680,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
}}
>
<summary className="tenant-section-bar">
<label className="tenant-timeslot-title" onClick={event => event.stopPropagation()}>
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
<span className="tenant-timeslot-title">
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onClick={event => event.stopPropagation()} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
<strong>{formatTenantDate(group.fileDate)}</strong>
</label>
</span>
<span className="tenant-history-meta"><b>{(reports.length + group.gpoBackups.length).toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
</summary>
{reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}>
@ -651,10 +718,11 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
<span className="tenant-report-copy"><b>GPO Backup</b><small>{record.fileName} · {record.gpoCount.toLocaleString('de-DE')} GPO{record.gpoCount === 1 ? '' : 's'}</small></span>
<span className="tenant-report-imported">{formatHistoryDate(record.submittedAt)}</span>
<b className="tenant-report-status" data-status={record.state}>{gpoStateLabels[record.state]}</b>
{record.state === 'dead-letter' ? <span className="tenant-gpo-row-actions">
<button className="btn compact" type="button" disabled={busy} onClick={() => void retryGpoBackup(record)}>Retry</button>
<button className="btn compact danger" type="button" disabled={busy} onClick={() => void deleteFailedGpoBackup(record)}>Delete</button>
</span> : <button className="btn tenant-gpo-view" type="button" onClick={() => setDetailSection('gpo-analysis')}>View</button>}
<span className="tenant-gpo-row-actions">
{record.state === 'dead-letter'
&& <button className="btn compact" type="button" disabled={busy} onClick={() => void retryGpoBackup(record)}>Retry</button>}
<button className="btn compact danger" type="button" disabled={busy || ['queued', 'running'].includes(record.state)} onClick={() => setGpoDeleteTarget(record)}>Delete</button>
</span>
</div>)}
</div>}
{affectedReports.length > 0 && <section className="tenant-processing-alert" data-status={hasProcessingError ? 'error' : 'update'} aria-label={hasProcessingError ? 'ASSESSOR processing errors require attention' : 'ASSESSOR processing profile updates available'}>
@ -678,11 +746,17 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
</div>;
})}
</section>}
{selected && <div className="tenant-timeslot-actions">
<button className="btn" type="button" onClick={() => setTimeslotDeleteTarget(group)}>Delete timeslot</button>
<div className="tenant-timeslot-actions">
<div className="tenant-timeslot-add-heading">
<h4>Add Entry</h4>
</div>
<FileImport targetProjectId={editedProject.id} targetAssessmentId={group.assessmentIds[0]} label="Import Scan"/>
<GpoFolderImport projectId={editedProject.id} assessmentId={group.assessmentIds[0]} label="Import GPO Folder" archiveLabel="Import GPO ZIP" showStatus={false}/>
</div>}
<div className="tenant-timeslot-delete-action">
<span>Entire timeslot</span>
<button className="btn danger" type="button" onClick={() => setTimeslotDeleteTarget(group)}>Delete timeslot</button>
</div>
</div>
</details>;
})}
</div>
@ -724,6 +798,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
projectData={projectData}
projectHistory={projectHistory}
projectGpoData={projectGpoData}
projectOpenReadiness={projectOpenReadiness}
selection={scanSelection}
timeslotMetrics={timeslotMetrics}
loading={loading}
@ -731,7 +806,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
comparisonError={timeslotMetricsError}
onCreate={openCreate}
onShow={openEdit}
onOpen={(project, scanRunIds) => onProjectSelected(project, scanRunIds)}
onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)}
onSelectProject={selectProject}
/>
<div className={`datahub-reference-row${SHOW_MITRE_MATRIX_LIBRARY ? '' : ' is-mitre-hidden'}`}>
@ -746,8 +821,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
</div>
</>}
{scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>}
{gpoDeleteTarget && <Dialog title="Delete GPO backup from the database?" onClose={() => !busy && setGpoDeleteTarget(null)}><div className="datahub-delete-warning"><strong>GPO Backup</strong><p>{gpoDeleteTarget.fileName} · {gpoDeleteTarget.gpoCount} GPO{gpoDeleteTarget.gpoCount === 1 ? '' : 's'}</p><p>The GPO backup, its analysis, evaluations, and derived findings will be permanently deleted. The timeslot and all other scan and GPO entries remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setGpoDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteGpoBackup()}>{busy ? 'Deleting …' : 'Yes, delete GPO backup'}</button></footer></div></Dialog>}
{timeslotDeleteTarget && <Dialog title="Delete timeslot from the database?" onClose={() => !busy && setTimeslotDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{formatTenantDate(timeslotDeleteTarget.fileDate)}</strong><p>{timeslotDeleteTarget.sourceCount} source{timeslotDeleteTarget.sourceCount === 1 ? '' : 's'} · {timeslotDeleteTarget.fileCount + timeslotDeleteTarget.gpoBackups.length} assigned file{timeslotDeleteTarget.fileCount + timeslotDeleteTarget.gpoBackups.length === 1 ? '' : 's'}</p><p>The timeslot, its scan reports, GPO backup data, findings, evidence, and related mappings will be permanently deleted. The client and all other timeslots remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setTimeslotDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteTimeslot()}>{busy ? 'Deleting …' : 'Yes, delete timeslot'}</button></footer></div></Dialog>}
{timeslotDialogOpen && <Dialog title="Create Timeslot" onClose={() => !busy && setTimeslotDialogOpen(false)}><form className="timeslot-dialog-form" onSubmit={createTimeslot}><label>Date<input className="input" type="date" value={timeslotDate} required onChange={event => setTimeslotDate(event.target.value)}/></label><p>Imports added afterwards are assigned to this dated assessment timeslot.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setTimeslotDialogOpen(false)}>Cancel</button><button className="btn primary" type="submit" disabled={busy || !timeslotDate}>{busy ? 'Creating …' : 'Create'}</button></footer></form></Dialog>}
{deleteTarget && <Dialog title="Permanently delete client?" onClose={() => !busy && setDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{deleteTarget.name}</strong><p>All assessments, scan reports, findings, reviews, and mappings for this client will be permanently deleted from the database. This action cannot be undone.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteProject()}>{busy ? 'Deleting …' : 'Yes, delete permanently'}</button></footer></div></Dialog>}
<OperationProgressDialog label={operationProgress}/>
</div>;
}

View File

@ -4,6 +4,8 @@ import { groupProjectImportFiles } from './datahub-import-groups';
import { summarizeClientProcessingReadiness } from './datahub-processing-readiness';
import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import type { TimeslotOpenReadiness } from './datahub-open-readiness';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
export interface DatahubTimeslotSelection {
projectId: string;
@ -15,6 +17,7 @@ interface DatahubHistoryOverviewProps {
projectData: Record<string, ImportFileRecord[]>;
projectHistory: Record<string, ProjectAssessmentHistoryResponse>;
projectGpoData: Record<string, GpoBackupStatusRecord[]>;
projectOpenReadiness?: Record<string, Record<string, TimeslotOpenReadiness>>;
selection: DatahubTimeslotSelection | null;
timeslotMetrics: DatahubTimeslotResultMetrics | null;
loading: boolean;
@ -22,7 +25,7 @@ interface DatahubHistoryOverviewProps {
comparisonError: string | null;
onCreate(): void;
onShow(project: AssessmentProject): void;
onOpen(project: AssessmentProject, scanRunIds: string[]): void;
onOpen(project: AssessmentProject, scanRunIds: string[], assessmentIds?: string[]): void;
onSelectProject(projectId: string): void;
}
@ -40,6 +43,7 @@ export function DatahubHistoryOverview({
projectData,
projectHistory,
projectGpoData,
projectOpenReadiness = {},
selection,
timeslotMetrics,
loading,
@ -66,7 +70,7 @@ export function DatahubHistoryOverview({
</section>
<section className="datahub-rack-module datahub-tenants-module" aria-label="Mandanten">
{loading ? <div className="datahub-loading"><i/><span>Mandanten werden geladen …</span></div> : (
{loading ? <PanelLoadingState message="Loading clients …" compact/> : (
<div className="datahub-tenant-card-rack">
{projects.map(project => {
const history = projectHistory[project.id];
@ -77,11 +81,12 @@ export function DatahubHistoryOverview({
projectGpoData[project.id] ?? []
);
const latest = groups[0];
const latestOpenable = groups.find(group => group.scanRunIds.length > 0);
const latestOpenable = groups.find(group => projectOpenReadiness[project.id]?.[group.id]?.canOpen);
const activeProcessingRunIds = groups.flatMap(group => group.scanRunIds);
const processingSummary = summarizeClientProcessingReadiness(
projectData[project.id] ?? [],
activeProcessingRunIds
activeProcessingRunIds,
projectGpoData[project.id] ?? []
);
const selected = selection?.projectId === project.id;
return <article className={`donut-metric-card donut-metric-card--all datahub-tenant-card${selected ? ' is-selected' : ''}`} key={project.id}>
@ -107,10 +112,10 @@ export function DatahubHistoryOverview({
className="btn datahub-tenant-show"
data-status={processingSummary.state}
type="button"
title={processingSummary.state === 'update' ? 'Manage recommended processing update' : processingSummary.state === 'error' ? 'Manage processing error' : 'Manage client'}
title={processingSummary.state === 'update' || processingSummary.state === 'error' ? processingSummary.label : 'Manage client'}
onClick={() => onShow(project)}
>Manage</button>}
<button className="btn primary datahub-tenant-open" type="button" disabled={!latestOpenable} title={latestOpenable ? 'Open latest timeslot' : 'No scan data available'} onClick={() => latestOpenable && onOpen(project, latestOpenable.scanRunIds)}>Open</button>
<button className="btn primary datahub-tenant-open" type="button" disabled={!latestOpenable} title={latestOpenable ? 'Open latest timeslot' : 'No scan data available'} onClick={() => latestOpenable && onOpen(project, latestOpenable.scanRunIds, latestOpenable.assessmentIds)}>Open</button>
</article>;
})}
<button className="donut-metric-card donut-metric-card--all datahub-tenant-card datahub-tenant-add" type="button" onClick={onCreate} aria-label="Mandant anlegen">

View File

@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { createGpoFolderTransport } from '../../gpo/gpo-folder-transport';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import {
createGpoFolderTransport,
validateGpoFolderSourceMetadata,
type GpoFolderFileSource
} from '../../gpo/gpo-folder-transport';
const assessorApi = new AssessorApiClient();
@ -18,11 +23,37 @@ interface Feedback {
message: string;
}
interface BrowserFileSystemFileHandle {
kind: 'file';
name: string;
getFile(): Promise<File>;
}
interface BrowserFileSystemDirectoryHandle {
kind: 'directory';
name: string;
values(): AsyncIterableIterator<BrowserFileSystemFileHandle | BrowserFileSystemDirectoryHandle>;
}
interface BrowserDirectoryPickerGlobal {
showDirectoryPicker?: (options?: { mode?: 'read' }) => Promise<BrowserFileSystemDirectoryHandle>;
}
interface SelectedBrowserFile {
file: File;
relativePath: string;
}
interface CollectedBrowserDirectory {
files: SelectedBrowserFile[];
unreadableEntries: number;
}
const stateLabels: Record<GpoBackupStatusRecord['state'], string> = {
queued: 'Queued',
running: 'Processing',
succeeded: 'Processed successfully',
partial: 'Partially processed',
partial: 'Imported with review items',
'dead-letter': 'Failed after three attempts',
'recovery-required': 'Recovery pending'
};
@ -44,6 +75,87 @@ function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
}
export function captureGpoSelectedFiles(entries: readonly SelectedBrowserFile[]): GpoFolderFileSource[] {
validateGpoFolderSourceMetadata(entries.map(entry => ({
relativePath: entry.relativePath,
sizeBytes: entry.file.size
})));
return entries.map(({ file, relativePath }) => {
// Das Lesen muss fuer alle Browser-Dateireferenzen noch synchron im
// Auswahlereignis beginnen. Spaeteres sequenzielles Oeffnen kann unter
// Windows bei Verzeichnis-Uploads mit NotFoundError abbrechen.
const captured = file.arrayBuffer().then(
buffer => ({ bytes: new Uint8Array(buffer) }),
() => ({ error: true as const })
);
return {
relativePath,
sizeBytes: file.size,
readBytes: async () => {
const result = await captured;
if ('error' in result) {
throw new Error('Eine ausgewaehlte GPO-Datei konnte nicht lokal gelesen werden. Bitte stellen Sie den gesamten Ordner lokal bereit und waehlen Sie ihn erneut aus.');
}
return result.bytes;
}
};
});
}
export function captureGpoFolderFiles(files: readonly File[]): GpoFolderFileSource[] {
return captureGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath })));
}
export async function captureReadableGpoSelectedFiles(
entries: readonly SelectedBrowserFile[]
): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number }> {
validateGpoFolderSourceMetadata(entries.map(entry => ({
relativePath: entry.relativePath,
sizeBytes: entry.file.size
})));
const captured: Array<GpoFolderFileSource | undefined> = await Promise.all(entries.map(async ({ file, relativePath }): Promise<GpoFolderFileSource | undefined> => {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
if (bytes.byteLength !== file.size) return undefined;
return {
relativePath,
sizeBytes: file.size,
readBytes: async () => bytes
};
} catch {
return undefined;
}
}));
const sources = captured.filter((source): source is GpoFolderFileSource => source !== undefined);
return { sources, unreadableEntries: entries.length - sources.length };
}
export async function collectGpoDirectoryFiles(
root: BrowserFileSystemDirectoryHandle
): Promise<CollectedBrowserDirectory> {
const selected: SelectedBrowserFile[] = [];
let unreadableEntries = 0;
const visit = async (directory: BrowserFileSystemDirectoryHandle, parentPath: string): Promise<void> => {
try {
for await (const entry of directory.values()) {
const relativePath = `${parentPath}/${entry.name}`;
if (entry.kind === 'directory') await visit(entry, relativePath);
else {
try {
selected.push({ file: await entry.getFile(), relativePath });
} catch {
unreadableEntries += 1;
}
}
}
} catch {
unreadableEntries += 1;
}
};
await visit(root, root.name);
return { files: selected, unreadableEntries };
}
export function GpoFolderImport({
projectId,
assessmentId,
@ -58,6 +170,8 @@ export function GpoFolderImport({
const [records, setRecords] = useState<GpoBackupStatusRecord[]>([]);
const [statusError, setStatusError] = useState<string>();
const [managedArtifactId, setManagedArtifactId] = useState<string>();
const [processingJob, setProcessingJob] = useState<{ artifactId: string; jobId: string }>();
const [progress, setProgress] = useState<string>();
const loadStatus = useCallback(async () => {
try {
@ -90,14 +204,27 @@ export function GpoFolderImport({
};
}, [loadStatus, projectId]);
useEffect(() => {
if (!processingJob) return;
const record = records.find(item => item.artifactId === processingJob.artifactId && item.jobId === processingJob.jobId);
if (record && !['queued', 'running'].includes(record.state)) {
setProcessingJob(undefined);
setProgress(undefined);
}
}, [processingJob, records]);
const retry = async (record: GpoBackupStatusRecord) => {
setManagedArtifactId(record.artifactId);
setProgress('Starting GPO processing');
setFeedback(null);
try {
await assessorApi.retryGpoBackup(projectId, record.artifactId);
const result = await assessorApi.retryGpoBackup(projectId, record.artifactId);
setProcessingJob({ artifactId: result.artifactId, jobId: result.jobId });
setProgress('Processing GPO backup');
setFeedback({ tone: 'success', message: 'The GPO analysis job was requeued.' });
await loadStatus();
} catch (reason) {
setProgress(undefined);
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'The GPO analysis job could not be restarted.' });
} finally {
setManagedArtifactId(undefined);
@ -109,7 +236,7 @@ export function GpoFolderImport({
setManagedArtifactId(record.artifactId);
setFeedback(null);
try {
const result = await assessorApi.deleteFailedGpoBackup(projectId, record.artifactId);
const result = await assessorApi.deleteGpoBackup(projectId, record.artifactId);
setFeedback({
tone: 'success',
message: result.storageRemoval === 'complete'
@ -124,30 +251,64 @@ export function GpoFolderImport({
}
};
const announceSubmission = async (file: File, successMessage: (deduplicated: boolean) => string) => {
setProgress('Uploading GPO backup');
const result = await assessorApi.submitGpoBackup(projectId, file, assessmentId);
setProcessingJob({ artifactId: result.artifactId, jobId: result.jobId });
setProgress('Processing GPO backup');
setFeedback({ tone: 'success', message: successMessage(result.deduplicated) });
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId, jobId: result.jobId } }));
await loadStatus();
};
const submitFolder = async (fileList: FileList) => {
const submitFolder = async (files: readonly File[]) => {
setBusy(true);
setProgress('Preparing GPO backup');
setFeedback(null);
try {
const files = Array.from(fileList);
if (files.some(file => !file.webkitRelativePath)) throw new Error('Please select a complete GPO backup folder.');
const transport = await createGpoFolderTransport(files.map(file => ({
relativePath: file.webkitRelativePath,
sizeBytes: file.size,
readBytes: async () => new Uint8Array(await file.arrayBuffer())
})));
const captured = await captureReadableGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath })));
if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Ordners konnte gelesen werden.');
const transport = await createGpoFolderTransport(captured.sources);
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
envelopeBytes.set(transport.bytes);
const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' });
await announceSubmission(envelope, deduplicated => deduplicated
? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files).`
: `GPO backup folder accepted · analysis job queued (${transport.fileCount} files).`);
? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files${captured.unreadableEntries ? `; ${captured.unreadableEntries} unreadable skipped` : ''}).`
: `GPO backup folder accepted · analysis job queued (${transport.fileCount} files${captured.unreadableEntries ? `; ${captured.unreadableEntries} unreadable skipped` : ''}).`);
} catch (reason) {
setProgress(undefined);
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' });
} finally {
setBusy(false);
}
};
const chooseFolder = async () => {
const picker = (globalThis as unknown as BrowserDirectoryPickerGlobal).showDirectoryPicker;
if (!picker) {
folderInputRef.current?.click();
return;
}
setBusy(true);
setProgress('Preparing GPO backup');
setFeedback(null);
try {
const directory = await picker({ mode: 'read' });
const collected = await collectGpoDirectoryFiles(directory);
if (!collected.files.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.');
const captured = await captureReadableGpoSelectedFiles(collected.files);
const unreadableEntries = collected.unreadableEntries + captured.unreadableEntries;
if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.');
const transport = await createGpoFolderTransport(captured.sources);
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
envelopeBytes.set(transport.bytes);
const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' });
await announceSubmission(envelope, deduplicated => deduplicated
? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files${unreadableEntries ? `; ${unreadableEntries} unreadable skipped` : ''}).`
: `GPO backup folder accepted · analysis job queued (${transport.fileCount} files${unreadableEntries ? `; ${unreadableEntries} unreadable skipped` : ''}).`);
} catch (reason) {
if (reason instanceof DOMException && reason.name === 'AbortError') { setProgress(undefined); return; }
setProgress(undefined);
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' });
} finally {
setBusy(false);
@ -156,6 +317,7 @@ export function GpoFolderImport({
const submitArchive = async (file: File) => {
setBusy(true);
setProgress('Uploading GPO backup');
setFeedback(null);
try {
if (!file.name.toLocaleLowerCase('en-US').endsWith('.zip')) {
@ -165,6 +327,7 @@ export function GpoFolderImport({
? 'GPO backup ZIP already exists · analysis job confirmed.'
: 'GPO backup ZIP accepted · analysis job queued.');
} catch (reason) {
setProgress(undefined);
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup ZIP could not be imported.' });
} finally {
setBusy(false);
@ -179,8 +342,9 @@ export function GpoFolderImport({
type="file"
multiple
onChange={event => {
if (event.target.files?.length) void submitFolder(event.target.files);
event.target.value = '';
const input = event.currentTarget;
const selectedFiles = Array.from(input.files ?? []);
if (selectedFiles.length) void submitFolder(selectedFiles).finally(() => { input.value = ''; });
}}
/>
<input
@ -189,11 +353,12 @@ export function GpoFolderImport({
type="file"
accept=".zip,application/zip"
onChange={event => {
if (event.target.files?.[0]) void submitArchive(event.target.files[0]);
event.target.value = '';
const input = event.currentTarget;
const selectedArchive = input.files?.[0];
if (selectedArchive) void submitArchive(selectedArchive).finally(() => { input.value = ''; });
}}
/>
<button className="btn primary" type="button" disabled={busy} onClick={() => folderInputRef.current?.click()}>
<button className="btn primary" type="button" disabled={busy} onClick={() => void chooseFolder()}>
{busy ? 'Checking GPO …' : label}
</button>
<button className="btn primary" type="button" disabled={busy} onClick={() => archiveInputRef.current?.click()}>
@ -231,6 +396,7 @@ export function GpoFolderImport({
</article>)}
</div>}
</section>}
<OperationProgressDialog label={progress}/>
</>;
}

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { Dialog } from '../../components/Dialog/Dialog';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import { TriangleIcon } from '../../components/TriangleIcon';
import type { MicrosoftSecurityBaselineRecord } from '../../domain/microsoft-security-baseline';
@ -20,6 +21,7 @@ export function MicrosoftBaselineLibrary() {
const inputRef = useRef<HTMLInputElement>(null);
const [records, setRecords] = useState<MicrosoftSecurityBaselineRecord[]>([]);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState<string>();
const [deleteTarget, setDeleteTarget] = useState<MicrosoftSecurityBaselineRecord | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -31,11 +33,15 @@ export function MicrosoftBaselineLibrary() {
const upload = async (files: FileList) => {
setBusy(true); setError(null); setFeedback(null);
try {
for (const file of Array.from(files)) await assessorApi.submitMicrosoftSecurityBaseline(file);
const selected = Array.from(files);
for (const [index, file] of selected.entries()) {
setProgress(`Importing baseline ${index + 1} of ${selected.length}`);
await assessorApi.submitMicrosoftSecurityBaseline(file);
}
await load();
setFeedback(`${files.length} Baseline-Paket${files.length === 1 ? '' : 'e'} im Katalog gespeichert.`);
} catch (reason) { setError(reason instanceof Error ? reason.message : 'Baseline-Upload fehlgeschlagen.'); }
finally { setBusy(false); }
finally { setBusy(false); setProgress(undefined); }
};
const deleteBaseline = async () => {
if (!deleteTarget) return;
@ -80,6 +86,7 @@ export function MicrosoftBaselineLibrary() {
})}
</div>}
</details>
{deleteTarget && <Dialog title="Delete security baseline?" onClose={() => !busy && setDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{deleteTarget.fileName}</strong><p>The imported package, its classifications, and its stored analyses will be permanently deleted. Baselines already referenced by policy evaluations remain protected and cannot be deleted.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteBaseline()}>{busy ? 'Deleting …' : 'Yes, delete baseline'}</button></footer></div></Dialog>}
{deleteTarget && <Dialog title="Delete security baseline?" onClose={() => !busy && setDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{deleteTarget.fileName}</strong><p>Baseline and derived evaluations will be deleted.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteBaseline()}>{busy ? 'Deleting …' : 'Delete baseline'}</button></footer></div></Dialog>}
<OperationProgressDialog label={progress}/>
</>;
}

View File

@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import type {
PolicyAnalysisActionStatus,
PolicyAnalysisOverview
@ -56,11 +57,13 @@ export function PolicyAnalysisActions({ projectId, overview, onChanged }: {
}) {
const [draft, setDraft] = useState<ProfileDraft>(emptyProfile);
const [busyId, setBusyId] = useState<string>();
const [progress, setProgress] = useState<string>();
const [feedback, setFeedback] = useState<{ tone: 'success' | 'error'; message: string }>();
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
const run = async (id: string, action: () => Promise<unknown>, success: string) => {
const run = async (id: string, action: () => Promise<unknown>, success: string, progressLabel: string) => {
setBusyId(id);
setProgress(progressLabel);
setFeedback(undefined);
try {
await action();
@ -72,6 +75,7 @@ export function PolicyAnalysisActions({ projectId, overview, onChanged }: {
return false;
} finally {
setBusyId(undefined);
setProgress(undefined);
}
};
@ -94,7 +98,7 @@ export function PolicyAnalysisActions({ projectId, overview, onChanged }: {
const created = await run('create-profile', () => assessorApi.createEnvironmentProfile(projectId, {
name: draft.name.trim(), domainKey: draft.domainKey.trim() || undefined,
supersedesProfileId: draft.supersedesProfileId || undefined, contexts
}), 'Environment profile persisted.');
}), 'Environment profile persisted.', 'Saving profile');
if (created) setDraft(emptyProfile());
};
@ -138,20 +142,21 @@ export function PolicyAnalysisActions({ projectId, overview, onChanged }: {
<div className="policy-action-stages">
<section><h4>Reprocessing</h4>{!overview.actions.reprocess.length ? <p>No processed GPO source available.</p> : overview.actions.reprocess.slice(0, 20).map(action => <article key={action.id}>
<div><strong>GPO source</strong><code title={action.artifactId}>{shortId(action.artifactId)}</code><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || action.status === 'blocked'} onClick={() => void run(action.id, () => assessorApi.reprocessGpoBackup(projectId, action.artifactId, action.expectedLatestProcessingRunId!), 'GPO reprocessing queued.')}>{busyId === action.id ? 'Queuing …' : action.status === 'blocked' ? 'Blocked' : 'Reprocess'}</button>
<button className="btn compact" type="button" disabled={busyId !== undefined || action.status === 'blocked'} onClick={() => void run(action.id, () => assessorApi.reprocessGpoBackup(projectId, action.artifactId, action.expectedLatestProcessingRunId!), 'GPO reprocessing queued.', 'Starting GPO processing')}>{busyId === action.id ? 'Queuing …' : action.status === 'blocked' ? 'Blocked' : 'Reprocess'}</button>
</article>)}</section>
<section><h4>Snapshot aggregation</h4>{!overview.actions.aggregate.length ? <p>Create a profile and process a GPO backup first.</p> : overview.actions.aggregate.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{profileById.get(action.environmentProfileId)?.name ?? 'Environment profile'}</strong><span>{action.sourceAnalysisRunIds.length} identity run</span><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.aggregatePolicySnapshot(projectId, { environmentProfileId: action.environmentProfileId, sourceAnalysisRunIds: action.sourceAnalysisRunIds }), 'Snapshot aggregation persisted.')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.aggregatePolicySnapshot(projectId, { environmentProfileId: action.environmentProfileId, sourceAnalysisRunIds: action.sourceAnalysisRunIds }), 'Snapshot aggregation persisted.', 'Calculating snapshot')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
<section><h4>Baseline evaluation</h4>{!overview.actions.evaluate.length ? <p>No snapshot aggregation available.</p> : overview.actions.evaluate.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{profileById.get(action.environmentProfileId)?.name ?? 'Environment profile'}</strong><span>{action.baselineAnalysisIds.length} baseline analysis source{action.baselineAnalysisIds.length === 1 ? '' : 's'}</span><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.evaluatePolicy(projectId, { aggregationRunId: action.aggregationRunId, baselineAnalysisIds: action.baselineAnalysisIds }), 'Policy evaluation persisted.')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.evaluatePolicy(projectId, { aggregationRunId: action.aggregationRunId, baselineAnalysisIds: action.baselineAnalysisIds }), 'Policy evaluation persisted.', 'Evaluating baseline')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
<section><h4>Finding Factory</h4>{!overview.actions.createFindings.length ? <p>No policy evaluation available.</p> : overview.actions.createFindings.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{action.eligibleResultCount} eligible result{action.eligibleResultCount === 1 ? '' : 's'}</strong><code title={action.evaluationRunId}>{shortId(action.evaluationRunId)}</code><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.createPolicyFindingRun(projectId, { evaluationRunId: action.evaluationRunId }), 'Finding Factory run persisted.')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.createPolicyFindingRun(projectId, { evaluationRunId: action.evaluationRunId }), 'Finding Factory run persisted.', 'Creating findings')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
</div>
<OperationProgressDialog label={progress}/>
</section>;
}

View File

@ -6,17 +6,21 @@ import type {
PolicyAnalysisReviewItem
} from '../../domain/policy-analysis-overview';
import { PolicyAnalysisActions } from './PolicyAnalysisActions';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
import { currentPolicyEvaluationsForWork } from './load-canonical-project-workspace';
import { buildPolicyWorkResults, countPolicyWorkResults, policyMisconfigurationImpact, type PolicyWorkCounts } from '../../evaluation/policy-work-results';
const assessorApi = new AssessorApiClient();
const categoryLabels: Record<PolicyAnalysisReviewCategory, string> = {
conflict: 'Conflict',
'unresolved-identity': 'Unresolved identity',
'catalog-gap': 'Catalog gap',
'parser-gap': 'Parser gap',
'partial-analysis': 'Partial analysis',
'targeting-unknown': 'Targeting unresolved',
'incomplete-evidence': 'Incomplete evidence',
'unresolved-identity': 'Unresolved setting',
'catalog-gap': 'Baseline mapping unavailable',
'parser-gap': 'Unsupported setting format',
'partial-analysis': 'Evaluation incomplete',
'targeting-unknown': 'Targeting not resolved',
'incomplete-evidence': 'Source evidence incomplete',
other: 'Review required'
};
@ -38,7 +42,7 @@ function ReviewRow({ item }: { item: PolicyAnalysisReviewItem }) {
</details>;
}
export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysisOverview }) {
export function PolicyAnalysisPanelView({ overview, counts, impact }: { overview: PolicyAnalysisOverview; counts?: PolicyWorkCounts; impact?: ReturnType<typeof policyMisconfigurationImpact> }) {
const visibleReviews = overview.current.reviewItems.slice(0, 100);
const currentSummary = overview.current.summary;
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
@ -46,32 +50,56 @@ export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysis
|| overview.summary.evaluationRuns > 0 || overview.summary.findingRuns > 0;
return <section className="policy-analysis-panel" aria-label="Policy analysis results">
<div className="datahub-section-heading">
<div><span>Policy Analysis</span><h3>GPO Baseline Evaluation</h3></div>
<strong>{currentSummary.evaluationRuns.toLocaleString('en-US')}</strong>
<div><span>GPO Analysis</span><h3>Baseline Evaluation</h3></div>
</div>
<p className="policy-analysis-boundary" role="note">
Backup configuration evidence only. Effective application is not verified without RSoP or GP Results.
Summary and review counts represent the current active snapshot chains. Evaluation history remains auditable below.
Backup settings are compared with the imported Microsoft Security Baselines. Effective application requires RSoP or GP Results.
</p>
{!hasPipelineData ? <div className="datahub-empty policy-analysis-empty">
<h3>No policy analysis runs yet</h3>
<p>Imported GPO backups remain processing evidence until snapshot aggregation and baseline evaluation have run.</p>
</div> : <>
<div className="policy-analysis-summary" aria-label="Policy analysis summary">
<article><span>Confirmed policy findings</span><strong>{currentSummary.policyFindings}</strong></article>
<article><span>Review records</span><strong>{currentSummary.reviewItems}</strong></article>
<article><span>Conflicts</span><strong>{currentSummary.conflicts}</strong></article>
<article><span>Partial runs</span><strong>{currentSummary.partialRuns}</strong></article>
<article><span>Catalog / parser gaps</span><strong>{currentSummary.catalogGaps + currentSummary.parserGaps}</strong></article>
<article><span>Unresolved identities</span><strong>{currentSummary.unresolvedIdentities}</strong></article>
<div className="policy-analysis-result-groups">
<section className="policy-analysis-result-group" aria-labelledby="gpo-import-summary">
<h4 id="gpo-import-summary">Import</h4>
<div className="policy-analysis-summary">
<article><span>GPO sources</span><strong>{currentSummary.identityAnalysisRuns}</strong></article>
<article><span>Evaluations</span><strong>{currentSummary.evaluationRuns}</strong></article>
</div>
</section>
<section className="policy-analysis-result-group" aria-labelledby="gpo-result-summary">
<h4 id="gpo-result-summary">Baseline results</h4>
<div className="policy-analysis-summary">
<article data-result="fail"><span>Misconfigured</span><strong>{counts?.Fail ?? '–'}</strong></article>
<article data-result="pass"><span>Pass</span><strong>{counts?.Pass ?? '–'}</strong></article>
<article><span>Conflicts</span><strong>{counts?.Conflicts ?? '–'}</strong></article>
<article><span>Duplicates</span><strong>{counts?.Duplicates ?? '–'}</strong></article>
<article><span>Not configured</span><strong>{counts?.['Not configured'] ?? '–'}</strong></article>
</div>
<p className="policy-analysis-muted">Baseline comparisons</p>
<div className="policy-analysis-summary">
<article><span>Misconfigured settings</span><strong>{impact?.settings ?? '–'}</strong></article>
<article><span>Affected GPO backups</span><strong>{impact?.backups ?? '–'}</strong></article>
</div>
</section>
<details className="policy-analysis-technical"><summary>Import diagnostics</summary><section className="policy-analysis-result-group" aria-labelledby="gpo-problem-summary">
<h4 id="gpo-problem-summary">Import issues</h4>
<div className="policy-analysis-summary policy-analysis-problems">
<article><span>Conflicts</span><strong>{currentSummary.conflicts}</strong><small>Different customer values for the same setting.</small></article>
<article><span>Unresolved settings</span><strong>{currentSummary.unresolvedIdentities}</strong><small>Could not be mapped to a known policy setting.</small></article>
<article><span>Unsupported settings</span><strong>{currentSummary.catalogGaps + currentSummary.parserGaps}</strong><small>No parser or baseline mapping is available.</small></article>
</div>
</section></details>
</div>
<div className="policy-analysis-columns">
<details className="policy-analysis-technical">
<summary>Technical details</summary>
<div className="policy-analysis-columns">
<section>
<div className="policy-analysis-subheading"><h4>Evaluation history</h4><span>{overview.evaluations.length}</span></div>
{!overview.evaluations.length ? <p className="policy-analysis-muted">No baseline evaluation has been persisted yet.</p>
: <div className="policy-evaluation-list">{overview.evaluations.map(run => <article key={run.evaluationRunId}>
<header><strong>{profileById.get(run.environmentProfileId)?.name ?? 'Environment profile'} · {run.status === 'complete' ? 'complete' : run.status}</strong><span>{formatTimestamp(run.completedAt)}</span></header>
<div><span>{run.matchCount} matches</span><span>{run.deviationCount} configured deviations</span><span>{run.notApplicableCount} not applicable</span><span>{run.reviewCount} review required</span></div>
<div><span>{run.matchCount} matches</span><span>{run.deviationCount} stored mismatches (including missing settings)</span><span>{run.notApplicableCount} not applicable</span><span>{run.reviewCount} review required</span></div>
<footer><strong>{run.findingCount} confirmed finding{run.findingCount === 1 ? '' : 's'}</strong>{run.rejectedFindingCount > 0 && <span>{run.rejectedFindingCount} factory rejection{run.rejectedFindingCount === 1 ? '' : 's'}</span>}</footer>
</article>)}</div>}
</section>
@ -81,20 +109,36 @@ export function PolicyAnalysisPanelView({ overview }: { overview: PolicyAnalysis
: <div className="policy-review-list">{visibleReviews.map(item => <ReviewRow item={item} key={item.id}/>)}</div>}
{overview.current.reviewItems.length > visibleReviews.length && <p className="policy-analysis-muted">Showing the first 100 deterministic review hints.</p>}
</section>
</div>
</div>
</details>
</>}
</section>;
}
export function PolicyAnalysisPanel({ projectId }: { projectId: string }) {
const [overview, setOverview] = useState<PolicyAnalysisOverview>();
const [counts, setCounts] = useState<PolicyWorkCounts>();
const [impact, setImpact] = useState<ReturnType<typeof policyMisconfigurationImpact>>();
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setOverview(await assessorApi.getPolicyAnalysisOverview(projectId));
const [nextOverview, observations, evaluations] = await Promise.all([
assessorApi.getPolicyAnalysisOverview(projectId),
queryAllUnifiedResults(query => assessorApi.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId, sourceKind: ['policy-observation']
}),
assessorApi.listPolicyEvaluations(projectId)
]);
setOverview(nextOverview);
const workResults = buildPolicyWorkResults(observations,
currentPolicyEvaluationsForWork(evaluations.records, new Set(observations.map(source => source.sourceResultId))));
setCounts(countPolicyWorkResults(workResults));
setImpact(policyMisconfigurationImpact(workResults));
setError(undefined);
} catch (reason) {
setCounts(undefined);
setImpact(undefined);
setError(reason instanceof Error ? reason.message : 'Policy analysis could not be loaded.');
} finally {
setLoading(false);
@ -115,13 +159,17 @@ export function PolicyAnalysisPanel({ projectId }: { projectId: string }) {
};
}, [load, projectId]);
if (loading && !overview) return <section className="policy-analysis-panel"><div className="datahub-loading"><i/><span>Loading policy analysis …</span></div></section>;
if (loading && !overview) return <section className="policy-analysis-panel"><PanelLoadingState message="Loading policy analysis …" compact/></section>;
if (error && !overview) return <section className="policy-analysis-panel"><div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div></section>;
return overview ? <>
<PolicyAnalysisActions projectId={projectId} overview={overview} onChanged={async () => {
await load();
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId } }));
}}/>
<PolicyAnalysisPanelView overview={overview}/>
{error && <div className="datahub-alert" role="alert">Analysis could not be refreshed.</div>}
<PolicyAnalysisPanelView overview={overview} counts={counts} impact={impact}/>
<details className="policy-analysis-controls">
<summary>Processing controls</summary>
<PolicyAnalysisActions projectId={projectId} overview={overview} onChanged={async () => {
await load();
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId } }));
}}/>
</details>
</> : null;
}

View File

@ -129,6 +129,27 @@ function rowsFor(
];
}
function occurrencesInDisplayOrder(occurrences: FindingOccurrence[]): FindingOccurrence[] {
return [...occurrences].sort((left, right) => (left.sourcePosition.rowIndex ?? 0) - (right.sourcePosition.rowIndex ?? 0));
}
function bindCanonicalFindings(sheet: DatasetSheet, occurrences: FindingOccurrence[]): DatasetSheet {
return {
...sheet,
analysis: {
...sheet.analysis,
records: sheet.analysis.records.map((record, index) => {
const occurrence = occurrences[index];
return occurrence ? {
...record,
findingOccurrenceId: occurrence.id,
findingDefinitionId: occurrence.definitionId
} : record;
})
}
};
}
function rowsForSourceTable(sourceRows: SourceTableRow[]): { rows: string[][]; highlightedRows: boolean[] } {
const labels = new Map<string, number>();
for (const row of sourceRows) {
@ -256,13 +277,13 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
// "Account list" je Regel erneut an), und ohne diesen Bezug bekaemen die
// Blaetter dieselbe ID - React sah doppelte Keys, und die Blattauswahl in
// der Werkzeugleiste haette auf mehrere Blaetter zugleich gezeigt.
const projectedSheet = makeSheet(sheetName, rows, rowsToCsv(rows), sourceFile, {
const projectedSheet = bindCanonicalFindings(makeSheet(sheetName, rows, rowsToCsv(rows), sourceFile, {
id: stableSheetId(sourceFile, sheetName, table.id),
sourceFile,
sourceSheet: table.name,
sourceTableIndex: table.index,
toolId: run?.tool.id
}, highlightedRows);
}, highlightedRows), occurrencesInDisplayOrder(tableOccurrences));
(tableOccurrences.length ? tableSheets : supportingSheets).push(projectedSheet);
}
@ -274,14 +295,14 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
const run = runById.get(file.scanRunId);
const scanDate = run?.importedAt.slice(0, 10) ?? '';
const sheetName = [run?.tool.name, scanDate, file.format].filter(Boolean).join(' · ');
tableSheets.push(makeSheet(sheetName, rows, rowsToCsv(rows), file.fileName, {
tableSheets.push(bindCanonicalFindings(makeSheet(sheetName, rows, rowsToCsv(rows), file.fileName, {
// Derselbe Grund wie oben: der Blattname aus Werkzeug, Scandatum und
// Format wiederholt sich ueber Dateien und Scanlaeufe hinweg.
id: stableSheetId(file.fileName, sheetName, file.id),
sourceFile: file.fileName,
sourceSheet: file.format,
toolId: run?.tool.id
}));
}), occurrencesInDisplayOrder(nativeOccurrences)));
}
const allHeaders = headersFor(occurrences, displayFields);
@ -302,7 +323,10 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
const tool = projectTool(models);
const aggregateName = project.name + ' · All Records';
const aggregate = makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true });
const aggregate = bindCanonicalFindings(
makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true }),
occurrences
);
const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0);
const sectionSheets = linkedSectionSheets(occurrences);
const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean));

View File

@ -0,0 +1,26 @@
import type { UnifiedResultFacetResult, UnifiedResultQuery } from '../../domain/unified-result';
import type { ScanImportGroup } from './datahub-import-groups';
export interface TimeslotOpenReadiness {
canOpen: boolean;
hasPolicyResults: boolean;
}
export async function loadTimeslotOpenReadiness(
readFacets: (query: UnifiedResultQuery) => Promise<UnifiedResultFacetResult>,
projectId: string,
groups: ScanImportGroup[]
): Promise<Record<string, TimeslotOpenReadiness>> {
return Object.fromEntries(await Promise.all(groups.map(async group => {
if (!group.assessmentIds.length) return [group.id, { canOpen: false, hasPolicyResults: false }];
try {
const result = await readFacets({ audience: 'internal-analysis', projectId, assessmentIds: group.assessmentIds,
sourceKind: ['scanner-finding', 'policy-observation'] });
const hasPolicyResults = result.facets.find(facet => facet.id === 'sourceKind')?.values
.some(value => value.value === 'policy-observation' && value.count > 0) ?? false;
return [group.id, { canOpen: result.total > 0, hasPolicyResults }];
} catch {
return [group.id, { canOpen: false, hasPolicyResults: false }];
}
})));
}

View File

@ -1,7 +1,8 @@
import type { ImportFileRecord } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility';
export type DatahubClientProcessingState = 'ready' | 'update' | 'error' | 'no-data';
export type DatahubClientProcessingState = 'ready' | 'processing' | 'update' | 'error' | 'no-data';
export interface DatahubClientProcessingSummary {
state: DatahubClientProcessingState;
@ -57,10 +58,11 @@ export function summarizeScanProcessingReadiness(files: ImportFileRecord[]): Dat
*/
export function summarizeClientProcessingReadiness(
files: ImportFileRecord[],
activeScanRunIds: readonly string[]
activeScanRunIds: readonly string[],
gpoBackups: readonly Pick<GpoBackupStatusRecord, 'state'>[] = []
): DatahubClientProcessingSummary {
if (activeScanRunIds.length === 0) {
return { state: 'no-data', label: 'No processing status available.' };
if (gpoBackups.some(record => record.state === 'dead-letter' || record.state === 'recovery-required')) {
return { state: 'error', label: 'GPO processing failed or requires recovery in a client timeslot.' };
}
const runIds = new Set(activeScanRunIds);
@ -68,10 +70,6 @@ export function summarizeClientProcessingReadiness(
.filter(file => runIds.has(file.scanRunId))
.map(file => processingReadiness(file.detectedTool.id || file.processedWith.profileId, file.processedWith));
if (statuses.length === 0) {
return { state: 'no-data', label: 'No processing status available.' };
}
if (statuses.some(status => ERROR_STATES.has(status))) {
return { state: 'error', label: 'Processing error requires attention in a client timeslot.' };
}
@ -80,5 +78,11 @@ export function summarizeClientProcessingReadiness(
return { state: 'update', label: 'ASSESSOR processing profile update available in a client timeslot.' };
}
return { state: 'ready', label: 'Processing ready across all client timeslots.' };
if (gpoBackups.some(record => record.state === 'queued' || record.state === 'running')) {
return { state: 'processing', label: 'GPO processing in progress.' };
}
return statuses.length || gpoBackups.length
? { state: 'ready', label: 'Processing ready across all client timeslots.' }
: { state: 'no-data', label: 'No processing status available.' };
}

View File

@ -1,14 +1,19 @@
import type { Assessment, CanonicalAssessmentModel } from '../../domain/canonical-model';
import type { ImportedDataset } from '../../domain/dataset';
import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../domain/policy-evaluation';
import type { UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
import { canonicalModelsToDataset } from './canonical-model-to-dataset';
import { appendPolicyObservationsToDataset } from './policy-observations-to-dataset';
import type { StoredMicrosoftSecurityBaselineAnalysis } from '../../domain/microsoft-security-baseline-analysis';
import { buildPolicyGuidance } from '../../profiles/group-policy/guidance';
export interface CanonicalProjectReader {
listProjectAssessments(projectId: string): Promise<Assessment[]>;
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>;
queryUnifiedResults?(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
listPolicyEvaluations?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>;
listMicrosoftSecurityBaselineAnalyses?(baselineId: string): Promise<{ records: StoredMicrosoftSecurityBaselineAnalysis[] }>;
}
export interface CanonicalProjectWorkspace {
@ -51,13 +56,41 @@ export function canonicalModelForScanRuns(
export function canonicalModelForScanRun(model: CanonicalAssessmentModel, scanRunId: string): CanonicalAssessmentModel | null {
return canonicalModelForScanRuns(model, [scanRunId]);
}
export function currentPolicyEvaluationsForWork(
records: StoredPolicyEvaluation[],
observationIds: Set<string>
): StoredPolicyEvaluation[] {
const candidates = records.filter(record =>
record.run.evaluator.id === POLICY_EVALUATION_ENGINE.id
&& record.run.evaluator.version === POLICY_EVALUATION_ENGINE.version
&& record.results.some(result => result.observationIds.some(id => observationIds.has(id)))
);
const selected = new Map<string, StoredPolicyEvaluation>();
for (const record of candidates) {
const backupIds = [...new Set(record.results.flatMap(result => result.backupInstanceIds))].sort();
const sourceKey = backupIds.length ? backupIds.join('\u001f') : record.run.aggregationRunId;
const current = selected.get(sourceKey);
const shouldReplace = !current
|| record.run.baselineAnalysisIds.length > current.run.baselineAnalysisIds.length
|| (record.run.baselineAnalysisIds.length === current.run.baselineAnalysisIds.length
&& `${record.run.completedAt}\u001f${record.run.id}` > `${current.run.completedAt}\u001f${current.run.id}`);
if (shouldReplace) selected.set(sourceKey, record);
}
return [...selected.values()].sort((left, right) => left.run.completedAt.localeCompare(right.run.completedAt));
}
export async function loadCanonicalProjectWorkspace(
reader: CanonicalProjectReader,
projectId: string,
scanRunIds?: string[]
scanRunIds?: string[],
selectedAssessmentIds?: string[]
): Promise<CanonicalProjectWorkspace> {
const assessments = await reader.listProjectAssessments(projectId);
const loadedModels = await Promise.all(assessments.map(assessment => reader.loadAssessmentModel(assessment.id)));
const selectedAssessments = selectedAssessmentIds
? assessments.filter(assessment => selectedAssessmentIds.includes(assessment.id))
: assessments;
const loadedModels = await Promise.all(selectedAssessments.map(assessment => reader.loadAssessmentModel(assessment.id)));
const models = scanRunIds?.length
? loadedModels.map(model => canonicalModelForScanRuns(model, scanRunIds))
.filter((model): model is CanonicalAssessmentModel => model !== null)
@ -66,14 +99,38 @@ export async function loadCanonicalProjectWorkspace(
const policyObservations = reader.queryUnifiedResults && assessmentIds.length
? await queryAllUnifiedResults(
query => reader.queryUnifiedResults!(query),
{ projectId, assessmentIds, sourceKind: ['policy-observation'] }
{ audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] }
)
: [];
const scannerDataset = canonicalModelsToDataset(models);
const evaluationList = policyObservations.length && reader.listPolicyEvaluations
? await reader.listPolicyEvaluations(projectId)
: undefined;
const evaluations = currentPolicyEvaluationsForWork(
evaluationList?.records ?? [],
new Set(policyObservations.map(result => result.sourceResultId))
);
const projectName = models[0]?.project.name ?? assessments.find(item => assessmentIds.includes(item.id))?.name ?? projectId;
const guidance = buildPolicyGuidance(evaluations);
// One request per referenced baseline, sequential to bound catalog memory. Optional
// documentation must not prevent opening otherwise usable assessment results.
if (reader.listMicrosoftSecurityBaselineAnalyses) {
const byBaseline = new Map<string, StoredPolicyEvaluation[]>();
for (const evaluation of evaluations) for (const baselineId of new Set(evaluation.results.map(result => result.baselineId))) {
byBaseline.set(baselineId, [...(byBaseline.get(baselineId) ?? []), {
...evaluation, results: evaluation.results.filter(result => result.baselineId === baselineId)
}]);
}
for (const [baselineId, records] of byBaseline) {
try {
const response = await reader.listMicrosoftSecurityBaselineAnalyses(baselineId);
for (const [id, value] of buildPolicyGuidance(records, response.records)) guidance.set(id, value);
} catch { /* Keep comparison/remediation; missing documentation is displayed explicitly. */ }
}
}
return {
models,
assessmentIds,
dataset: appendPolicyObservationsToDataset(scannerDataset, projectName, policyObservations)
dataset: appendPolicyObservationsToDataset(scannerDataset, projectName, policyObservations, evaluations, guidance)
};
}

View File

@ -1,61 +1,94 @@
import type { ImportedDataset } from '../../domain/dataset';
import type { UnifiedResultProjection } from '../../domain/unified-result';
import type { StoredPolicyEvaluation } from '../../domain/policy-evaluation';
import { buildPolicyWorkResults } from '../../evaluation/policy-work-results';
import { rowsToCsv } from '../../exports/csv-export';
import { makeSheet } from '../../importers/sheet-factory';
import { buildPolicyGuidance, type PolicyGuidanceMap } from '../../profiles/group-policy/guidance';
import { formatPolicySources } from '../../profiles/group-policy/source-presentation';
import { buildPolicyConflictPresentation, buildPolicyDuplicatePresentation, serializePolicyConflictPresentation, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
function display(value: unknown): string {
if (value === undefined) return '';
if (value === null) return 'null';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value);
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
export function appendPolicyObservationsToDataset(
dataset: ImportedDataset | null,
projectName: string,
results: UnifiedResultProjection[]
results: UnifiedResultProjection[],
evaluations: StoredPolicyEvaluation[] = [],
guidance: PolicyGuidanceMap = buildPolicyGuidance(evaluations)
): ImportedDataset | null {
const observations = results.filter(result => result.sourceKind === 'policy-observation');
if (!observations.length) return dataset;
const entries = buildPolicyWorkResults(results, evaluations);
if (!entries.length) return dataset;
const headers = [
'Title', 'Status', 'Scope', 'Delivery Mechanism', 'Policy State', 'Identity Status',
'Technical Identity', 'Configured Value', 'Diagnostics', 'Source Path', 'Backup Instance', 'Imported'
'Title', 'Category', 'Status', 'Scope', 'Configured Value', 'Baseline value', 'Baseline',
'Comparison', 'Registry path', 'Sources', 'Technical Identity', 'Imported', 'Diagnostics', 'Policy type', 'Policy section',
'Description', 'Remediation', 'Description source', 'Conflict', POLICY_CONFLICT_DETAILS, 'Duplicates', POLICY_DUPLICATE_DETAILS
];
const observationRows = observations.map(result => [
result.title,
result.policyIdentityStatus === 'resolved' ? 'Observed' : 'Investigate',
result.policyScope ?? '',
result.policyDeliveryMechanism ?? '',
result.policyState ?? '',
result.policyIdentityStatus ?? 'unresolved',
result.policyIdentityKey ?? '',
display(result.policyValues),
result.policyDiagnosticCodes?.join(', ') ?? '',
result.sourceLocator?.objectPath ?? '',
result.policyBackupInstanceId ?? '',
result.importedAt
]);
const rows = [headers, ...observationRows];
const policyEntries = entries.map(entry => {
const comparison = entry.comparison;
const conflict = buildPolicyConflictPresentation(entry);
const duplicate = buildPolicyDuplicatePresentation(entry);
const configured = comparison?.customerState === 'missing' ? 'Not configured'
: comparison?.comparator.actual ?? entry.sources.flatMap(source => source.policyValues ?? []);
const target = comparison?.identity.target;
const rawRegistry = entry.sources.flatMap(source => source.policyValues ?? [])
.find(value => value && typeof value === 'object' && !Array.isArray(value) && value.kind === 'registry-policy');
const registryPath = target && 'hive' in target && target.hive ? `${target.hive}\\${target.key}`
: rawRegistry && typeof rawRegistry === 'object' && !Array.isArray(rawRegistry)
? `${entry.scope === 'user' ? 'HKEY_CURRENT_USER' : 'HKEY_LOCAL_MACHINE'}\\${rawRegistry.key}` : '';
return {
importedAt: entry.importedAt,
row: [
entry.title, 'GPOs', entry.status, entry.scope, display(configured),
display(comparison?.comparator.baseline), comparison?.baselineLabel ?? '',
comparison?.diagnosticCodes.includes('policy-evaluation-baseline-consensus-conflict')
? 'Conflicting baseline recommendations'
: entry.status === 'Conflicts' ? 'Different values or types for the same setting'
: entry.status === 'Duplicates' ? 'Identical setting in multiple source entries'
: comparison?.customerState === 'missing' ? 'Baseline setting is not configured'
: entry.status === 'Pass' ? 'Matches baseline' : 'Differs from baseline',
registryPath,
formatPolicySources(entry.sources),
entry.identity, entry.importedAt,
[...new Set([...(comparison?.diagnosticCodes ?? []),
...entry.sources.flatMap(source => source.policyDiagnosticCodes ?? [])])].join(', '),
target?.kind ?? entry.sources[0]?.policyDeliveryMechanism ?? '', target && 'section' in target ? target.section : '',
comparison ? guidance.get(comparison.id)?.description || 'Not available in imported baseline metadata.' : '',
comparison ? guidance.get(comparison.id)?.remediation ?? '' : '',
comparison ? guidance.get(comparison.id)?.source ?? '' : '',
conflict?.summary ?? '', conflict ? serializePolicyConflictPresentation(conflict) : '',
duplicate?.summary ?? '', duplicate ? serializePolicyConflictPresentation(duplicate) : ''
]
};
});
const policyRows = policyEntries.map(entry => entry.row);
const rows = [headers, ...policyRows];
const sheet = makeSheet(
'Group Policy · Observations',
'GPOs',
rows,
rowsToCsv(rows),
projectName,
{ id: `gpo-policy-observations-${projectName}`, sourceFile: projectName, sourceSheet: 'Policy Observations', toolId: 'group-policy' }
{ id: `gpo-policy-observations-${projectName}`, sourceFile: projectName, sourceSheet: 'GPOs', toolId: 'group-policy' }
);
if (!dataset) {
const lastModified = Math.max(...observations.map(result => Date.parse(result.importedAt)), 0);
const lastModified = Math.max(
...policyEntries.map(entry => Date.parse(entry.importedAt)),
0
);
return {
fileName: `${projectName} · Group Policy`,
fileName: `${projectName} · GPOs`,
fileSize: 0,
lastModified,
format: 'GPO Backup',
rawText: '',
sheets: [sheet],
profileId: 'generic',
profileVersion: 'gpo-policy-observation-1.0.0',
scanTool: 'Group Policy'
profileVersion: 'gpo-work-comparison-1.2.0',
scanTool: 'GPOs'
};
}
const [first, ...rest] = dataset.sheets;
@ -67,14 +100,14 @@ export function appendPolicyObservationsToDataset(
const source = new Map(sourceHeaders.map((header, index) => [header, row[index] ?? '']));
return aggregateHeaders.map(header => source.get(header) ?? '');
});
const policyRows = observationRows.map((row, rowIndex) => {
const aggregatePolicyRows = policyRows.map((row, rowIndex) => {
const source = new Map(headers.map((header, index) => [header, row[index] ?? '']));
source.set('Source: File', projectName);
source.set('Source: Sheet', 'Policy Observations');
source.set('Source: Scan', `Group Policy · ${observations[rowIndex].importedAt}`);
source.set('Source: Sheet', 'GPOs');
source.set('Source: Scan', `GPOs · ${policyEntries[rowIndex]?.importedAt ?? ''}`);
return aggregateHeaders.map(header => source.get(header) ?? '');
});
const aggregateRows = [aggregateHeaders, ...projectRows, ...policyRows];
const aggregateRows = [aggregateHeaders, ...projectRows, ...aggregatePolicyRows];
return makeSheet(first.name, aggregateRows, rowsToCsv(aggregateRows), first.sourceFile ?? projectName, {
id: first.id,
sourceFile: first.sourceFile,
@ -85,6 +118,6 @@ export function appendPolicyObservationsToDataset(
return {
...dataset,
sheets: aggregate ? [aggregate, sheet, ...rest] : [sheet],
scanTool: dataset.scanTool === 'Group Policy' ? dataset.scanTool : 'Multiple Tools'
scanTool: dataset.scanTool === 'GPOs' ? dataset.scanTool : 'Multiple Tools'
};
}

View File

@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { UnifiedResultFacetResult, UnifiedResultProjection, UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
import { ResultsFilterPanel } from './ResultsFilterPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { downloadText } from '../../exports/text-download';
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
import { hasSelectedTimeslotScope } from '../../app/work-scope';
const assessorApi = new AssessorApiClient();
const PAGE_SIZE = 50;
@ -28,9 +30,10 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
const [exporting, setExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const hasTimeslotScope = hasSelectedTimeslotScope(scanRunIds, assessmentIds);
const load = useCallback(async () => {
if (backendStatus !== 'connected' || !projectId || !scanRunIds?.length) {
if (backendStatus !== 'connected' || !projectId || !hasTimeslotScope) {
setFacetResult(null);
setQueryResult(null);
setLoading(false);
@ -56,7 +59,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
} finally {
setLoading(false);
}
}, [backendStatus, projectId, scanRunIds, assessmentIds, filters, limit]);
}, [backendStatus, projectId, scanRunIds, assessmentIds, hasTimeslotScope, filters, limit]);
useEffect(() => { void load(); }, [load]);
@ -66,7 +69,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
setFilters(next);
};
const exportFilteredResults = async () => {
if (!projectId || !scanRunIds?.length) return;
if (!projectId || !hasTimeslotScope) return;
setExporting(true);
setError(null);
try {
@ -101,7 +104,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
</div>;
}
if (!scanRunIds?.length) {
if (!hasTimeslotScope) {
return <div className="datahub-page">
<div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to browse exactly this assessment state.</p></div>
</div>;
@ -109,7 +112,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
return <div className="datahub-page results-browser-page">
{error &&<div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
{loading && !queryResult ? <div className="datahub-loading"><i/><span>Loading results …</span></div> : <div className="results-browser-layout">
{loading && !queryResult ? <PanelLoadingState message="Loading results …"/> : <div className="results-browser-layout">
<ResultsFilterPanel facets={facetResult?.facets ?? []} selected={filters} onChange={updateFilters}
onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })}/>
<div className="results-browser-table-wrap">
@ -141,12 +144,13 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection; expanded: boolean; onToggle(): void }) {
const environment = item.sourceKind === 'environment-observation';
const policyObservation = item.sourceKind === 'policy-observation';
const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel);
const contextRecord = environment || policyObservation;
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
<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="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.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="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
@ -171,17 +175,22 @@ function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection
<li>Compatibility: {item.processingCompatibilityKey ?? 'unknown'} · {processingStatusLabel(item.processingStatus)}</li>
{item.processingStatus === 'reimport-recommended' && <li>Reprocessing: {item.reprocessingAvailable ? 'retained source available' : 'original source must be uploaded again'}</li>}
{environment ? <>
<li>Subject: {item.environmentSubjectType ?? '–'}{item.environmentSubjectIdentity ? ` · ${item.environmentSubjectIdentity}` : ''}</li>
<li>Subject class: {item.environmentSubjectType ?? '–'}</li>
<li>State: {item.environmentState ?? 'unknown'} · Value type: {item.environmentValueType ?? 'unknown'}</li>
<li>Original value: {displayEnvironmentValue(item.environmentOriginalValue)}</li>
<li>Section: {item.environmentSectionKey ?? '–'}</li>
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
<li>Disclosure: {item.supplementalDisclosureClass ?? '–'} · policy {item.supplementalDisclosurePolicyVersion ?? '–'}</li>
</> : policyObservation ? <>
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
<li>Policy state: {item.policyState ?? '–'} · Identity: {item.policyIdentityStatus ?? 'unresolved'}</li>
<li>Backup instance: {item.policyBackupInstanceId ?? '–'}</li>
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
</> : policyComparison ? <>
<li>Baseline: {item.policyBaselineLabel}</li>
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
<li>Actual: {displayEnvironmentValue(item.policyActualValue)}</li>
<li>Baseline value: {displayEnvironmentValue(item.policyBaselineValue)}</li>
<li>Comparison: {item.policyComparisonRelation ?? '–'} · Customer state: {item.policyCustomerState ?? '–'}</li>
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
</> : <>
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
<li>Affected Object: {item.affectedAsset ?? '–'}</li>

View File

@ -0,0 +1,135 @@
import type { FindingReview, ReviewPriority, ReviewTarget, ReviewWorkflowStatus } from '../../domain/canonical-model';
import type { ImportedDataset } from '../../domain/dataset';
import { defaultReview, type Review } from '../../domain/review';
const markerFields = {
'question-needed': 'markerQuestion',
'permission-blocked': 'markerBlocked',
'quick-win': 'markerQuickwin',
'external-team': 'markerExternal',
'not-relevant': 'markerNotRelevant'
} as const;
const workflowByLabel: Record<string, ReviewWorkflowStatus> = {
Open: 'open',
'In Review': 'in-progress',
'Clarification Needed': 'deferred',
Actionable: 'accepted',
Blocked: 'deferred',
Done: 'resolved',
'Not Relevant': 'not-relevant'
};
const labelByWorkflow: Record<ReviewWorkflowStatus, string> = {
unreviewed: 'Open',
open: 'Open',
'in-progress': 'In Review',
accepted: 'Actionable',
deferred: 'Blocked',
resolved: 'Done',
'not-relevant': 'Not Relevant'
};
const priorityByLabel: Record<string, ReviewPriority> = {
Critical: 'critical', High: 'high', Medium: 'medium', Low: 'low', None: 'none'
};
function optional(value: string): string | undefined {
return value === '' ? undefined : value;
}
export function reviewTargetKey(target: ReviewTarget): string {
return target.kind === 'occurrence' ? `occurrence:${target.occurrenceId}` : `definition:${target.definitionId}`;
}
export function reviewFromDatabase(review: FindingReview): Review {
const markers = new Set(review.markers);
return {
...defaultReview(),
include: review.includeInActionPlan,
status: review.workflowLabel ?? labelByWorkflow[review.workflowStatus],
priority: review.priority[0].toUpperCase() + review.priority.slice(1),
owner: review.owner ?? '',
due: review.dueAt ?? '',
prerequisites: review.prerequisites ?? '',
access: review.requiredAccess ?? '',
nextStep: review.nextStep ?? '',
evidence: review.acceptanceEvidence ?? '',
comment: review.comment ?? '',
markerQuestion: markers.has('question-needed'),
markerBlocked: markers.has('permission-blocked'),
markerQuickwin: markers.has('quick-win'),
markerExternal: markers.has('external-team'),
markerNotRelevant: markers.has('not-relevant')
};
}
export function reviewToDatabase(
review: Review,
target: ReviewTarget,
current: FindingReview | undefined,
now: string
): FindingReview {
const knownMarkers = new Set(Object.keys(markerFields));
const markers = (current?.markers ?? []).filter(marker => !knownMarkers.has(marker));
for (const [marker, field] of Object.entries(markerFields) as Array<[keyof typeof markerFields, typeof markerFields[keyof typeof markerFields]]>) {
if (review[field]) markers.push(marker);
}
return {
id: current?.id ?? `review-${target.kind}-${target.kind === 'occurrence' ? target.occurrenceId : target.definitionId}`,
target,
workflowStatus: workflowByLabel[review.status] ?? 'open',
workflowLabel: review.status,
priority: priorityByLabel[review.priority] ?? 'medium',
includeInActionPlan: review.include,
owner: optional(review.owner),
dueAt: optional(review.due),
comment: optional(review.comment),
prerequisites: optional(review.prerequisites),
requiredAccess: optional(review.access),
nextStep: optional(review.nextStep),
acceptanceEvidence: optional(review.evidence),
markers,
createdAt: current?.createdAt ?? now,
updatedAt: now,
revision: (current?.revision ?? 0) + 1
};
}
export function reviewsEqual(left: Review, right: Review): boolean {
return Object.keys(defaultReview()).every(key => left[key as keyof Review] === right[key as keyof Review]);
}
export function recordsByOccurrence(dataset: ImportedDataset): Map<string, string[]> {
const result = new Map<string, string[]>();
for (const record of dataset.sheets.flatMap(sheet => sheet.analysis.records)) {
if (!record.findingOccurrenceId) continue;
const ids = result.get(record.findingOccurrenceId) ?? [];
if (!ids.includes(record.id)) ids.push(record.id);
result.set(record.findingOccurrenceId, ids);
}
return result;
}
export function hydrateWorkspaceReviews(
dataset: ImportedDataset,
databaseReviews: FindingReview[],
localReviews: Record<string, Review>
): { reviews: Record<string, Review>; persisted: Map<string, FindingReview> } {
const result = { ...localReviews };
const persisted = new Map<string, FindingReview>();
for (const review of databaseReviews) {
const key = reviewTargetKey(review.target);
const existing = persisted.get(key);
if (!existing || review.revision > existing.revision) persisted.set(key, review);
}
for (const [occurrenceId, recordIds] of recordsByOccurrence(dataset)) {
const definitionId = dataset.sheets.flatMap(sheet => sheet.analysis.records)
.find(record => record.findingOccurrenceId === occurrenceId)?.findingDefinitionId;
const database = persisted.get(`occurrence:${occurrenceId}`)
?? (definitionId ? persisted.get(`definition:${definitionId}`) : undefined);
const value = database ? reviewFromDatabase(database) : recordIds.map(id => localReviews[id]).find(Boolean);
if (value) recordIds.forEach(id => { result[id] = value; });
}
return { reviews: result, persisted };
}

View File

@ -19,7 +19,7 @@ function belongsToTimeslot(selection: readonly string[] | undefined, timeslotRun
}
export function WorkTimeslotControl() {
const { activeScanRunIds, backendStatus, openProject, targetProjectId } = useApp();
const { activeScanRunIds, activeAssessmentIds, backendStatus, openProject, targetProjectId } = useApp();
const [timeslots, setTimeslots] = useState<ScanImportGroup[]>([]);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
@ -39,14 +39,15 @@ export function WorkTimeslotControl() {
assessorApi.listProjectImportFiles(targetProjectId)
]);
setTimeslots(groupProjectImportFiles(files, history.assessments, history.scanRuns)
.filter(timeslot => timeslot.scanRunIds.length > 0));
.filter(timeslot => timeslot.scanRunIds.length > 0
|| timeslot.assessmentIds.some(id => activeAssessmentIds?.includes(id))));
} catch {
setTimeslots([]);
setLoadFailed(true);
} finally {
setLoading(false);
}
}, [backendStatus, targetProjectId]);
}, [activeAssessmentIds, backendStatus, targetProjectId]);
useEffect(() => {
void loadTimeslots();
@ -56,17 +57,19 @@ export function WorkTimeslotControl() {
}, [loadTimeslots]);
const selectedValue = useMemo(() => {
return timeslots.find(timeslot => belongsToTimeslot(activeScanRunIds, timeslot.scanRunIds))?.id ?? '';
}, [activeScanRunIds, timeslots]);
return timeslots.find(timeslot => belongsToTimeslot(activeScanRunIds, timeslot.scanRunIds)
|| (!activeScanRunIds?.length && Boolean(activeAssessmentIds?.length)
&& activeAssessmentIds!.every(id => timeslot.assessmentIds.includes(id))))?.id ?? '';
}, [activeAssessmentIds, activeScanRunIds, timeslots]);
useEffect(() => {
if (!targetProjectId || loading || timeslots.length === 0 || selectedValue) return;
if (!targetProjectId || loading || timeslots.length === 0 || selectedValue || activeAssessmentIds?.length) return;
const latest = timeslots[0];
const selectionKey = `${targetProjectId}:${latest.id}`;
if (automaticSelectionRef.current === selectionKey) return;
automaticSelectionRef.current = selectionKey;
void openProject(targetProjectId, latest.scanRunIds);
}, [loading, openProject, selectedValue, targetProjectId, timeslots]);
}, [activeAssessmentIds, loading, openProject, selectedValue, targetProjectId, timeslots]);
const placeholder = loading
? 'Loading timeslots …'
@ -84,7 +87,7 @@ export function WorkTimeslotControl() {
onChange={event => {
if (!targetProjectId) return;
const selected = timeslots.find(timeslot => timeslot.id === event.target.value);
if (selected) void openProject(targetProjectId, selected.scanRunIds);
if (selected) void openProject(targetProjectId, selected.scanRunIds, selected.assessmentIds);
}}
>
{timeslots.length === 0

View File

@ -193,11 +193,16 @@ function categoricalValues(values: string[], severityScale = false): DynamicFace
return entries.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'en-US'));
}
function categoryIsUseful(column: ColumnProfile, values: string[], role?: CanonicalFieldRole): boolean {
if (values.length < 2 || column.type === 'Text' || column.type === 'leer') return false;
function categoryIsUseful(column: ColumnProfile, values: string[], role?: CanonicalFieldRole, recordCount = values.length): boolean {
const isPartialSingleCategory = role === 'category' && values.length > 0 && values.length < recordCount;
if ((values.length < 2 && !isPartialSingleCategory)
|| (column.type === 'Text' && role !== 'category') || column.type === 'leer') return false;
const unique = new Set(values.map(normalized)).size;
const averageLength = values.reduce((sum, value) => sum + value.length, 0) / values.length;
if (unique < 2 || unique > MAX_CATEGORY_VALUES || averageLength > 72) return false;
// A single explicit category is still a useful selector when other rows are
// uncategorized, for example GPO findings appended to scanner results.
if ((unique < 2 && !isPartialSingleCategory)
|| unique > MAX_CATEGORY_VALUES || averageLength > 72) return false;
const semanticallyUseful = role === 'severity' || role === 'status' || role === 'category' || role === 'asset' || role === 'framework-reference';
return semanticallyUseful || unique <= 10 || unique / values.length <= 0.5;
}
@ -264,7 +269,8 @@ function fieldFacets(sheet: DatasetSheet, dataset: ImportedDataset | null | unde
return sheet.analysis.columns.flatMap((column, columnIndex) => {
const recognition = fields.find(field => field.columnIndex === columnIndex);
const role = recognition?.appliedRole === 'additional' ? undefined : recognition?.appliedRole;
const recognizedRole = recognition?.appliedRole === 'additional' ? undefined : recognition?.appliedRole;
const role = recognizedRole ?? (/^(?:category|kategorie)$/i.test(clean(column.header)) ? 'category' : undefined);
if (role === 'score' || normalized(column.header) === 'score') return [];
if (role && narrativeRoles.has(role)) return [];
if (narrativeHeaders.test(clean(column.header))) return [];
@ -298,7 +304,7 @@ function fieldFacets(sheet: DatasetSheet, dataset: ImportedDataset | null | unde
: [columnIndex];
const rawValues = sheet.analysis.records.flatMap(record => recordValues(record, columnIndexes));
if (!categoryIsUseful(column, rawValues, role)) return [];
if (!categoryIsUseful(column, rawValues, role, sheet.analysis.records.length)) return [];
if (isSiblingGroup) emittedGroups.add(groupKey);
// Beim Zusammenfassen entfaellt die Positionsnummer aus der Beschriftung.
const label = roleLabels[role ?? 'additional']
@ -415,9 +421,22 @@ export function deriveDynamicFacets(
// Arbeitsreihenfolge: Ergebnisstatus und die fachlichen Achsen zuerst,
// danach Review- und Herkunftsfacetten.
return [profileFacet, cisCategories, ...fields, ...reviewFacets, sourceFiles]
.filter((facet): facet is DynamicFacet => Boolean(facet))
.slice(0, MAX_VISIBLE_FACETS);
const available = [profileFacet, cisCategories, ...fields, ...reviewFacets, sourceFiles]
.filter((facet): facet is DynamicFacet => Boolean(facet));
const visible = available.slice(0, MAX_VISIBLE_FACETS);
const scanSource = available.find(facet => /^(?:quelle|source):\s*scan$/i.test(clean(facet.label)));
if (scanSource && !visible.some(facet => facet.id === scanSource.id)) {
// Die projektweite Werkzeugauswahl ist eine bindende Herkunftsfacette.
// GPO-spezifische Zusatzspalten duerfen sie nicht aus der begrenzten
// Filterleiste verdraengen. Sie bleibt als letzter sekundaerer Filter
// sichtbar, ohne die vorderen fachlichen Arbeitsfilter umzuschichten.
let replaceIndex = visible.length - 1;
while (replaceIndex >= 0 && isPrimaryFacet(visible[replaceIndex])) replaceIndex -= 1;
if (replaceIndex >= 0) visible.splice(replaceIndex, 1, scanSource);
else if (visible.length) visible.splice(visible.length - 1, 1, scanSource);
else visible.push(scanSource);
}
return visible;
}
/**

View File

@ -7,6 +7,8 @@ export interface GpoFolderFileSource {
readBytes(): Promise<Uint8Array>;
}
export type GpoFolderFileMetadata = Pick<GpoFolderFileSource, 'relativePath' | 'sizeBytes'>;
export interface GpoFolderTransport {
fileName: string;
bytes: Uint8Array;
@ -15,6 +17,11 @@ export interface GpoFolderTransport {
rootName?: string;
}
export interface GpoFolderTransportLimits {
maximumSourceBytes?: number;
maximumTransportBytes?: number;
}
export class GpoFolderTransportError extends Error {
constructor(readonly code: string, message: string) {
super(message);
@ -25,7 +32,9 @@ export class GpoFolderTransportError extends Error {
interface PreparedEntry {
relativePath: string;
encodedPath: Uint8Array;
bytes: Uint8Array;
payloadBytes: Uint8Array;
uncompressedBytes: number;
compressionMethod: 0 | 8;
crc32: number;
}
@ -73,10 +82,10 @@ function writeLocalHeader(view: DataView, offset: number, entry: PreparedEntry):
view.setUint32(offset, 0x04034b50, true);
view.setUint16(offset + 4, 20, true);
view.setUint16(offset + 6, utf8Flag, true);
view.setUint16(offset + 8, 0, true);
view.setUint16(offset + 8, entry.compressionMethod, true);
view.setUint32(offset + 14, entry.crc32, true);
view.setUint32(offset + 18, entry.bytes.byteLength, true);
view.setUint32(offset + 22, entry.bytes.byteLength, true);
view.setUint32(offset + 18, entry.payloadBytes.byteLength, true);
view.setUint32(offset + 22, entry.uncompressedBytes, true);
view.setUint16(offset + 26, entry.encodedPath.byteLength, true);
return offset + 30;
}
@ -86,20 +95,20 @@ function writeCentralHeader(view: DataView, offset: number, entry: PreparedEntry
view.setUint16(offset + 4, 20, true);
view.setUint16(offset + 6, 20, true);
view.setUint16(offset + 8, utf8Flag, true);
view.setUint16(offset + 10, 0, true);
view.setUint16(offset + 10, entry.compressionMethod, true);
view.setUint32(offset + 16, entry.crc32, true);
view.setUint32(offset + 20, entry.bytes.byteLength, true);
view.setUint32(offset + 24, entry.bytes.byteLength, true);
view.setUint32(offset + 20, entry.payloadBytes.byteLength, true);
view.setUint32(offset + 24, entry.uncompressedBytes, true);
view.setUint16(offset + 28, entry.encodedPath.byteLength, true);
view.setUint32(offset + 42, localOffset, true);
return offset + 46;
}
function buildZip(entries: PreparedEntry[]): Uint8Array {
const localBytes = entries.reduce((sum, entry) => sum + 30 + entry.encodedPath.byteLength + entry.bytes.byteLength, 0);
function buildZip(entries: PreparedEntry[], maximumTransportBytes: number): Uint8Array {
const localBytes = entries.reduce((sum, entry) => sum + 30 + entry.encodedPath.byteLength + entry.payloadBytes.byteLength, 0);
const centralBytes = entries.reduce((sum, entry) => sum + 46 + entry.encodedPath.byteLength, 0);
const totalBytes = localBytes + centralBytes + 22;
if (!Number.isSafeInteger(totalBytes) || totalBytes > defaultImportResourcePolicy.maximumFileBytes || totalBytes > 0xffffffff) {
if (!Number.isSafeInteger(totalBytes) || totalBytes > maximumTransportBytes || totalBytes > 0xffffffff) {
fail('gpo-folder-transport-size-limit', 'Der ausgewählte GPO-Ordner überschreitet das zulässige Transportlimit.');
}
const output = new Uint8Array(totalBytes);
@ -111,8 +120,8 @@ function buildZip(entries: PreparedEntry[]): Uint8Array {
offset = writeLocalHeader(view, offset, entry);
output.set(entry.encodedPath, offset);
offset += entry.encodedPath.byteLength;
output.set(entry.bytes, offset);
offset += entry.bytes.byteLength;
output.set(entry.payloadBytes, offset);
offset += entry.payloadBytes.byteLength;
}
const centralOffset = offset;
entries.forEach((entry, index) => {
@ -128,7 +137,60 @@ function buildZip(entries: PreparedEntry[]): Uint8Array {
return output;
}
export async function createGpoFolderTransport(sources: readonly GpoFolderFileSource[]): Promise<GpoFolderTransport> {
function boundedLimit(value: number | undefined, fallback: number, ceiling: number, name: string): number {
const resolved = value ?? fallback;
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > ceiling) {
fail('gpo-folder-limit-invalid', `${name} ist ungueltig.`);
}
return resolved;
}
async function compressedPayload(bytes: Uint8Array): Promise<{ payloadBytes: Uint8Array; compressionMethod: 0 | 8 }> {
if (typeof CompressionStream === 'undefined') return { payloadBytes: bytes, compressionMethod: 0 };
const compressor = new CompressionStream('deflate-raw');
const reader = compressor.readable.getReader();
const chunks: Uint8Array[] = [];
const readCompressed = (async () => {
let totalBytes = 0;
while (true) {
const result = await reader.read();
if (result.done) break;
chunks.push(result.value);
totalBytes += result.value.byteLength;
}
const output = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
})();
const writer = compressor.writable.getWriter();
await writer.write(bytes.slice());
await writer.close();
const compressed = await readCompressed;
return compressed.byteLength < bytes.byteLength
? { payloadBytes: compressed, compressionMethod: 8 }
: { payloadBytes: bytes, compressionMethod: 0 };
}
export function validateGpoFolderSourceMetadata(
sources: readonly GpoFolderFileMetadata[],
limits: GpoFolderTransportLimits = {}
): { maximumSourceBytes: number; maximumTransportBytes: number } {
const maximumSourceBytes = boundedLimit(
limits.maximumSourceBytes,
defaultImportResourcePolicy.maximumZipUncompressedBytes,
defaultImportResourcePolicy.maximumZipUncompressedBytes,
'Das GPO-Quelllimit'
);
const maximumTransportBytes = boundedLimit(
limits.maximumTransportBytes,
defaultImportResourcePolicy.maximumFileBytes,
defaultImportResourcePolicy.maximumFileBytes,
'Das GPO-Transportlimit'
);
if (!sources.length) fail('gpo-folder-empty', 'Der ausgewählte GPO-Backupordner enthält keine Dateien.');
if (sources.length > defaultImportResourcePolicy.maximumZipEntries || sources.length > 0xffff) {
fail('gpo-folder-entry-limit', 'Der ausgewählte GPO-Ordner enthält zu viele Dateien.');
@ -140,9 +202,17 @@ export async function createGpoFolderTransport(sources: readonly GpoFolderFileSo
}
return sum + source.sizeBytes;
}, 0);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > defaultImportResourcePolicy.maximumFileBytes) {
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maximumSourceBytes) {
fail('gpo-folder-source-size-limit', 'Der ausgewählte GPO-Ordner überschreitet das zulässige Uploadlimit.');
}
return { maximumSourceBytes, maximumTransportBytes };
}
export async function createGpoFolderTransport(
sources: readonly GpoFolderFileSource[],
limits: GpoFolderTransportLimits = {}
): Promise<GpoFolderTransport> {
const { maximumTransportBytes } = validateGpoFolderSourceMetadata(sources, limits);
const encoder = new TextEncoder();
const collisionKeys = new Set<string>();
@ -162,15 +232,22 @@ export async function createGpoFolderTransport(sources: readonly GpoFolderFileSo
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.sizeBytes) {
fail('gpo-folder-file-size-mismatch', 'Deklarierte und gelesene Größe einer GPO-Datei stimmen nicht überein.');
}
prepared.push({ relativePath, encodedPath, bytes: bytes.slice(), crc32: crc32(bytes) });
const payload = await compressedPayload(bytes);
prepared.push({
relativePath,
encodedPath,
...payload,
uncompressedBytes: bytes.byteLength,
crc32: crc32(bytes)
});
}
prepared.sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0);
const root = rootName(prepared);
return {
fileName: transportFileName(root),
bytes: buildZip(prepared),
bytes: buildZip(prepared, maximumTransportBytes),
fileCount: prepared.length,
sourceBytes: prepared.reduce((sum, entry) => sum + entry.bytes.byteLength, 0),
sourceBytes: prepared.reduce((sum, entry) => sum + entry.uncompressedBytes, 0),
rootName: root
};
}

View File

@ -25,7 +25,9 @@ function effectiveBackupSetId(instance: GpoBackupInstance): string {
}
function groupKey(instance: GpoBackupInstance): string {
const domain = instance.domain.status === 'resolved' ? instance.domain.key : `unknown:${instance.id}`;
const domain = instance.backupSet.scope === 'selected-gpos' && instance.backupSet.evidence === 'user-selection'
? 'user-selected-backup-set'
: instance.domain.status === 'resolved' ? instance.domain.key : `unknown:${instance.id}`;
const backupSet = effectiveBackupSetId(instance);
return JSON.stringify([instance.projectId, domain, backupSet]);
}
@ -75,8 +77,13 @@ function snapshotForGroup(
}
const derivedBackupSet = !first.backupSet.id;
const backupSetId = effectiveBackupSetId(first);
const selectedUpload = first.backupSet.scope === 'selected-gpos' && first.backupSet.evidence === 'user-selection';
const mixedDomains = splitSetIds.has(backupSetId);
if (derivedBackupSet) diagnostics.add('gpo-backup-set-derived-from-source-artifact');
if (splitSetIds.has(backupSetId)) {
if (mixedDomains && selectedUpload) {
diagnostics.add('gpo-backup-set-domain-mixed');
indeterminate = true;
} else if (mixedDomains) {
diagnostics.add('gpo-backup-set-domain-split');
inconsistent = true;
}
@ -141,7 +148,7 @@ function snapshotForGroup(
version: 1,
id: `gpo-snapshot-${hash(JSON.stringify([groupKey(first), instanceIds]))}`,
projectId: first.projectId,
domain: first.domain,
domain: mixedDomains && selectedUpload ? { status: 'unknown' } : first.domain,
backupSetId,
collectionWindow: collectionRange,
instanceIds,

View File

@ -31,6 +31,8 @@ const UNKNOWN_DIMENSIONS: StatusDimensions = { legacy: 'unknown', outcome: 'unkn
* Hinweis, kein bestandener oder fehlgeschlagener Test).
*/
const fallbackRules: Array<{ pattern: RegExp; legacy: CanonicalFindingStatus; outcome?: CanonicalOutcome }> = [
{ pattern: /^not configured$/, legacy: 'open', outcome: 'informational' },
{ pattern: /^misconfigured$/, legacy: 'failed' },
{ pattern: /failed\s*to\s*run|execution.?error|technical.?error/, legacy: 'error' },
{ pattern: /not\s*(?:applicable|relevant)|irrelevant|nicht\s*(?:anwendbar|relevant)/, legacy: 'not-applicable' },
{ pattern: /not\s*(?:run|selected|executed)|nicht\s*(?:ausgef(?:ue|ü)hrt|ausgew(?:ae|ä)hlt)|skipped|cancell?ed|abgebrochen/, legacy: 'not-run' },
@ -43,6 +45,7 @@ const fallbackRules: Array<{ pattern: RegExp; legacy: CanonicalFindingStatus; ou
{ pattern: /error|fehler/, legacy: 'error' },
{ pattern: /manual/, legacy: 'manual' },
{ pattern: /investigate|pr(?:ue|ü)fen/, legacy: 'investigate' },
{ pattern: /^conflicts?$|^duplicates?$/, legacy: 'open', outcome: 'warning' },
{ pattern: /open|offen/, legacy: 'open' },
{ pattern: /pass/, legacy: 'passed' },
{ pattern: /fail/, legacy: 'failed' }
@ -108,7 +111,7 @@ export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string):
const field = record.fields.find(candidate =>
SCAN_PROVENANCE_LABEL.test(candidate.label.trim()) && candidate.value);
const toolName = clean(field?.value.split(/\s*(?:\u00c2\u00b7|\u00b7)\s*/)[0] ?? '').toLocaleLowerCase('en-US');
return toolIdByName.get(toolName) ?? fallbackToolId;
return toolName === 'gpos' ? 'group-policy' : toolIdByName.get(toolName) ?? fallbackToolId;
}
/**

View File

@ -168,7 +168,7 @@ export interface ArtifactDeletionRequest {
lastErrorCode?: string;
}
export interface FailedGpoArtifactDeletionResult {
export interface GpoArtifactDeletionResult {
artifact: ImportArtifact;
request: ArtifactDeletionRequest;
}
@ -233,7 +233,7 @@ export interface AssessorRepository {
failIngestionJob(jobId: string, workerId: string, failedAt: string, errorCode: string, nextAvailableAt: string): Promise<IngestionJob>;
listIngestionJobs(projectId: string): Promise<IngestionJob[]>;
retryIngestionJob(projectId: string, jobId: string, retriedAt: string): Promise<IngestionJob>;
deleteFailedGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest): Promise<FailedGpoArtifactDeletionResult>;
deleteGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest): Promise<GpoArtifactDeletionResult>;
listArtifactDeletionRequests(dueAt: string, limit: number): Promise<ArtifactDeletionRequest[]>;
completeArtifactDeletionRequest(requestId: string): Promise<void>;
failArtifactDeletionRequest(requestId: string, failedAt: string, errorCode: string, nextAttemptAt: string): Promise<void>;

View File

@ -40,6 +40,7 @@ import { normalizeComparisonText } from '../comparison/comparison-normalization'
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
import {
createEnvironmentUnifiedResult,
createCurrentPolicyEvaluationUnifiedResults,
createCurrentPolicyObservationUnifiedResults,
createPolicyUnifiedResult,
createScannerUnifiedResult,
@ -556,34 +557,48 @@ export class LocalAssessorRepository implements AssessorRepository {
});
}
private unifiedResults(): UnifiedResultProjection[] {
private unifiedResults(audience: UnifiedResultQuery['audience'] = 'result-db'): UnifiedResultProjection[] {
const policy = this.state.policyFindingRuns.flatMap(record => record.occurrences.flatMap(occurrence => {
const definition = this.state.findingDefinitions.find(item => item.id === occurrence.definitionId);
return definition ? [createPolicyUnifiedResult({ definition, occurrence })] : [];
const comparison = this.state.policyEvaluations.find(item => item.run.id === occurrence.evaluationRunId)
?.results.find(item => item.id === occurrence.evaluationResultId);
return definition ? [createPolicyUnifiedResult({ definition, occurrence, comparison })] : [];
}));
const projectedEvaluationResultIds = new Set(this.state.policyFindingRuns
.flatMap(record => record.occurrences.map(occurrence => occurrence.evaluationResultId)));
const policyEvaluations = createCurrentPolicyEvaluationUnifiedResults(
this.state.policyEvaluations,
this.state.policySnapshotAggregations,
this.state.gpoPolicyAnalyses,
this.state.importArtifacts,
projectedEvaluationResultIds
);
const environment = this.state.environmentObservations.flatMap(observation => {
const scanRun = this.state.scanRuns.find(item => item.id === observation.scanRunId);
const importFile = this.state.importFiles.find(item => item.id === observation.importFileId);
const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined;
const project = assessment ? this.state.projects.find(item => item.id === assessment.projectId) : undefined;
if (!scanRun || scanRun.supersededByScanRunId || !importFile || !assessment || !project) return [];
return [createEnvironmentUnifiedResult({ project, assessment, scanRun, importFile, observation })];
const result = createEnvironmentUnifiedResult({ project, assessment, scanRun, importFile, observation });
return result ? [result] : [];
});
const policyObservations = createCurrentPolicyObservationUnifiedResults(
this.state.importArtifacts,
this.state.processingRuns,
this.state.gpoBackupInstances,
this.state.gpoPolicyAnalyses
);
return [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policy];
const policyObservations = audience === 'internal-analysis'
? createCurrentPolicyObservationUnifiedResults(
this.state.importArtifacts,
this.state.processingRuns,
this.state.gpoBackupInstances,
this.state.gpoPolicyAnalyses
)
: [];
return [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyEvaluations, ...policy];
}
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
return clone(queryUnifiedResultItems(this.unifiedResults(), query));
return clone(queryUnifiedResultItems(this.unifiedResults(query.audience), query));
}
async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise<UnifiedResultFacetResult> {
return clone(deriveUnifiedResultFacets(this.unifiedResults(), query));
return clone(deriveUnifiedResultFacets(this.unifiedResults(query.audience), query));
}
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
@ -592,7 +607,7 @@ export class LocalAssessorRepository implements AssessorRepository {
}
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
return clone(listUnifiedResultCategories(filterUnifiedResults(this.unifiedResults(), query)));
return clone(listUnifiedResultCategories(filterUnifiedResults(this.unifiedResults(query.audience), query)));
}
async adoptScanRunReplacement(projectId: string, replacedScanRunId: string, replacementScanRunId: string, supersededAt: string): Promise<void> {
@ -887,7 +902,11 @@ export class LocalAssessorRepository implements AssessorRepository {
throw new RepositoryNotFoundError('Mindestens eine Baselineanalyse der Policy-Evaluation ist nicht vorhanden.');
}
const states = new Map(aggregation.states.map(state => [state.id, state]));
const baselineIdentities = new Set(sources.flatMap(source => source.analysis.settings.flatMap(setting =>
setting.identity.status === 'resolved' ? [setting.identity.identity.id] : [])));
if (record.results.some(result => {
if (!baselineIdentities.has(result.identity.id)) return true;
if (!result.aggregationStateId) return result.customerState !== 'missing';
const state = states.get(result.aggregationStateId);
return !state || state.identity.id !== result.identity.id;
})) {
@ -966,10 +985,12 @@ export class LocalAssessorRepository implements AssessorRepository {
if (!record) return null;
const analysisIds = new Set(this.state.microsoftSecurityBaselineAnalyses
.filter(item => item.analysis.baselineId === baselineId).map(item => item.analysis.id));
if (this.state.policyEvaluations.some(item => item.run.baselineAnalysisIds.some(id => analysisIds.has(id)))) {
throw new RepositoryConflictError('The Microsoft baseline is referenced by a policy evaluation and cannot be deleted.');
}
const next = clone(this.state);
const evaluationIds = new Set(next.policyEvaluations
.filter(item => item.run.baselineAnalysisIds.some(id => analysisIds.has(id)))
.map(item => item.run.id));
next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationIds.has(item.run.evaluationRunId));
next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationIds.has(item.run.id));
next.microsoftSecurityBaselines = next.microsoftSecurityBaselines.filter(item => item.id !== baselineId);
next.microsoftSecurityBaselineClassifications = next.microsoftSecurityBaselineClassifications.filter(item => item.baselineId !== baselineId);
next.microsoftSecurityBaselineAnalyses = next.microsoftSecurityBaselineAnalyses.filter(item => item.analysis.baselineId !== baselineId);
@ -1045,6 +1066,7 @@ export class LocalAssessorRepository implements AssessorRepository {
const next = clone(this.state);
let changed = false;
next.ingestionJobs.forEach(job => {
if (claim.projectId && job.projectId !== claim.projectId) return;
if (job.status === 'running' && job.attempts >= job.maxAttempts && Date.parse(job.leaseExpiresAt ?? '') <= now) {
job.status = 'dead-letter';
job.lastErrorCode = job.lastErrorCode ?? 'lease-expired';
@ -1056,6 +1078,7 @@ export class LocalAssessorRepository implements AssessorRepository {
});
const eligible = next.ingestionJobs.filter(job =>
job.attempts < job.maxAttempts
&& (!claim.projectId || job.projectId === claim.projectId)
&& (!allowedKinds || allowedKinds.has(job.kind))
&& ((job.status === 'queued' && Date.parse(job.availableAt) <= now)
|| (job.status === 'running' && Date.parse(job.leaseExpiresAt ?? '') <= now))
@ -1133,24 +1156,44 @@ export class LocalAssessorRepository implements AssessorRepository {
return clone(next.ingestionJobs[index]);
}
async deleteFailedGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest) {
async deleteGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest) {
const artifact = this.state.importArtifacts.find(item => item.id === artifactId && item.projectId === projectId);
if (!artifact || artifact.storage.kind !== 'backend-controlled' || artifact.kind !== 'source-file' || !artifact.relativePath.startsWith('gpo-backups/')) {
throw new RepositoryNotFoundError('Das fehlgeschlagene GPO-Artefakt ist im Mandanten nicht vorhanden.');
throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.');
}
const jobs = this.state.ingestionJobs.filter(item => item.artifactId === artifactId && item.projectId === projectId && item.kind === 'analyze-gpo-backup');
if (!jobs.length || jobs.some(item => item.status !== 'dead-letter')) throw new RepositoryConflictError('Nur ein endgültig fehlgeschlagener GPO-Import kann gelöscht werden.');
if (jobs.some(item => item.status === 'queued' || item.status === 'running')) throw new RepositoryConflictError('Ein laufender GPO-Import kann nicht gelöscht werden.');
if (request.version !== 1 || request.projectId !== projectId || request.artifactId !== artifactId
|| request.storageKey !== artifact.storage.storageKey || request.attempts !== 0
|| !request.id.trim() || !Number.isFinite(Date.parse(request.createdAt)) || !Number.isFinite(Date.parse(request.nextAttemptAt))) {
throw new RepositoryValidationError('Der Artefakt-Löschauftrag ist ungültig.');
}
const next = clone(this.state);
const processingRunIds = new Set(next.processingRuns.filter(item => item.artifactId === artifactId).map(item => item.id));
const analysisRunIds = new Set(next.gpoPolicyAnalyses
.filter(item => item.run.artifactId === artifactId || processingRunIds.has(item.run.sourceProcessingRunId))
.map(item => item.run.id));
const aggregationRunIds = new Set(next.policySnapshotAggregations
.filter(item => item.run.sourceAnalysisRunIds.some(id => analysisRunIds.has(id)))
.map(item => item.run.id));
const evaluationRunIds = new Set(next.policyEvaluations
.filter(item => aggregationRunIds.has(item.run.aggregationRunId))
.map(item => item.run.id));
const removedPolicyFindingRuns = next.policyFindingRuns.filter(item => evaluationRunIds.has(item.run.evaluationRunId));
const removedDefinitionIds = new Set(removedPolicyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)));
next.importArtifacts = next.importArtifacts.filter(item => item.id !== artifactId);
next.processingRuns = next.processingRuns.filter(item => item.artifactId !== artifactId);
next.ingestionJobs = next.ingestionJobs.filter(item => item.artifactId !== artifactId);
next.gpoBackupInstances = next.gpoBackupInstances.filter(item => item.artifactId !== artifactId);
next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => item.run.artifactId !== artifactId);
next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !analysisRunIds.has(item.run.id));
next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => !aggregationRunIds.has(item.run.id));
next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationRunIds.has(item.run.id));
next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationRunIds.has(item.run.evaluationRunId));
const remainingDefinitionIds = new Set([
...next.findingOccurrences.map(item => item.definitionId),
...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId))
]);
next.findingDefinitions = next.findingDefinitions.filter(item => !removedDefinitionIds.has(item.id) || remainingDefinitionIds.has(item.id));
const existing = next.artifactDeletionRequests.find(item => item.storageKey === request.storageKey);
if (!existing) appendUnique(next.artifactDeletionRequests, [request], 'Artefakt-Löschauftrag');
this.replaceState(next);

View File

@ -24,6 +24,9 @@ const genericStatusFilters = [
patterns: [/^fail(?:ed)?$/, /^not passed?$/, /^nicht bestanden$/, /^ioes? (?:found|detected)$/, /^matched$/, /^detected$/, /^open$/, /^offen$/, /^investigate$/, /^warning$/, /^pr(?:ue|ü)fen$/]
},
{ value: 'passed', label: 'Passed', patterns: [/^pass(?:ed)?$/, /^bestanden$/] },
{ value: 'not-configured', label: 'Not configured', patterns: [/^not configured$/] },
{ value: 'conflicts', label: 'Conflicts', patterns: [/^conflicts$/] },
{ value: 'duplicates', label: 'Duplicates', patterns: [/^duplicates$/] },
{ value: 'not-applicable', label: 'Not Applicable', patterns: [/^not (?:applicable|relevant)$/, /^irrelevant$/, /^nicht (?:anwendbar|relevant)$/] },
{ value: 'not-run', label: 'Not Run', patterns: [/^not (?:run|selected|executed)$/, /^skipped$/, /^cancell?ed$/, /^nicht ausgef(?:ue|ü)hrt$/] },
{ value: 'error', label: 'Error', patterns: [/^error$/, /^failed to run$/, /^fehler$/] }

View File

@ -0,0 +1,57 @@
import type { FindingRecord } from '../../domain/finding';
import { toolIdForRecord } from '../../importers/status-normalization';
/** Display-only taxonomy. Package names describe the benchmark, not device applicability. */
export function policyFindingCategories(record: FindingRecord, toolId?: string): string[] {
if (toolIdForRecord(record, toolId) !== 'group-policy') return [];
const field = (label: string) => record.fields.find(item => item.label.trim().toLowerCase() === label)?.value.trim() ?? '';
const baseline = field('baseline');
const path = field('registry path').replace(/\//g, '\\').toLowerCase();
const kind = field('policy type').toLowerCase();
// Older stored projections retain the native section in the technical title.
const titleSection = /^(system access|privilege rights|registry values|event audit|kerberos policy|system services|group membership|file security|registry keys)\s*[·:]/i.exec(record.title)?.[1] ?? '';
const section = (field('policy section') || titleSection).toLowerCase().replace(/[\s_-]/g, '');
const categories: string[] = [];
// Prefer the setting's explicit registry family over a broader Windows package label.
const office = /\\(?:policies\\)?microsoft\\office(?:\\|$)/i.test(path);
const edge = /\\(?:policies\\)?microsoft\\edge(?:\\|$)/i.test(path);
if (office) categories.push('Office');
else if (edge) categories.push('Edge');
else if (/\b(?:office|microsoft 365 apps|m365 apps)\b/i.test(baseline)) categories.push('Office');
else if (/\bmicrosoft edge\b/i.test(baseline)) categories.push('Edge');
else if (/\bwindows\b/i.test(baseline)) categories.push('Windows');
if (kind === 'advanced-audit-policy' || (kind === 'security-template' && section === 'eventaudit')
|| /^system audit subcategory\s*[·:]/i.test(record.title)) categories.push('Audit');
else if (/\\(?:windows defender|windows defender security center)(?:\\|$)/i.test(path)) categories.push('Defender');
else if (/\\(?:windowsfirewall|firewallpolicy)(?:\\|$)/i.test(path)) categories.push('Firewall');
else if (/\\(?:fve|bitlocker)(?:\\|$)/i.test(path)) categories.push('BitLocker');
else if (/\\(?:laps|admPwd)(?:\\|$)/i.test(path)) categories.push('LAPS');
else if (kind === 'security-template' || ((!categories.length || categories[0] === 'Windows') && /\bsecurity baseline\b/i.test(baseline))) categories.push('Security');
const sections: Record<string, string> = {
systemaccess: 'System Access', privilegerights: 'Privilege Rights', registryvalues: 'Registry Values',
kerberospolicy: 'Kerberos', systemservices: 'System Services', groupmembership: 'Restricted Groups',
filesecurity: 'Filesystem Security', registrykeys: 'Registry Permissions'
};
if (Object.hasOwn(sections, section)) {
if (!categories.includes('Security')) categories.push('Security');
categories.push(sections[section]);
if (section === 'systemaccess') {
if (/\b(?:lockoutbadcount|lockoutduration|resetlockoutcount)\b/i.test(record.title)) categories.push('Account Lockout');
else if (/\b(?:minimumpasswordage|maximumpasswordage|minimumpasswordlength|passwordcomplexity|passwordhistorysize|cleartextpassword)\b/i.test(record.title)) categories.push('Password Policy');
}
if (section === 'privilegerights' && /\bSe\w*LogonRight\b/i.test(record.title)) categories.push('Logon Rights');
}
// Registry branches are structural, not additional security findings.
const specialized = ['Office', 'Edge', 'Audit', 'Defender', 'Firewall', 'BitLocker', 'LAPS'].some(c => categories.includes(c));
if (!section && !specialized && path) {
categories.push('Registry');
const root = path.replace(/^(?:hkey_local_machine|hkey_current_user|hklm|hkcu|machine|user)\\/, '');
if (root.startsWith('software\\')) categories.push('Software');
else if (root.startsWith('system\\')) categories.push('System');
}
return [...new Set(categories)];
}

View File

@ -0,0 +1,117 @@
import type { FindingRecord } from '../../domain/finding';
import type { GpoPolicyValue } from '../../domain/gpo-policy';
import type { DatasetSheet } from '../../domain/dataset';
import { buildRecord } from '../../analysis/record-builder';
import { policySettingValueSignature, type PolicyWorkResult } from '../../evaluation/policy-work-results';
import { formatPolicyComparisonValue } from './presentation';
export const POLICY_CONFLICT_DETAILS = 'GPO conflict details';
export const POLICY_DUPLICATE_DETAILS = 'GPO duplicate details';
interface ConflictTable { name: string; headers: string[]; rows: string[][]; emphasis: string[] }
interface ConflictPresentation { version: 1; summary: string; context: string; tables: ConflictTable[] }
export function serializePolicyConflictPresentation(value: ConflictPresentation): string {
// The generic tabular reader folds whitespace; escape it inside this JSON envelope.
return JSON.stringify(value).replace(/\s/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
}
const registryTypes: Record<number, string> = { 0: 'REG_NONE', 1: 'REG_SZ', 2: 'REG_EXPAND_SZ', 3: 'REG_BINARY', 4: 'REG_DWORD', 5: 'REG_DWORD_BIG_ENDIAN', 7: 'REG_MULTI_SZ', 11: 'REG_QWORD' };
/** Group typed variants without choosing a winner or changing comparison semantics. */
export function buildPolicyConflictPresentation(result: PolicyWorkResult): ConflictPresentation | undefined {
return result.status === 'Conflicts' ? buildPolicyEvidencePresentation(result) : undefined;
}
export function buildPolicyDuplicatePresentation(result: PolicyWorkResult): ConflictPresentation | undefined {
return result.status === 'Duplicates' ? buildPolicyEvidencePresentation(result) : undefined;
}
function buildPolicyEvidencePresentation(result: PolicyWorkResult): ConflictPresentation | undefined {
if (result.sources.length < 2) return undefined;
const duplicate = result.status === 'Duplicates';
const variants = new Map<string, { label: string; value: string; type: string; sources: Set<string> }>();
const sourceRows: string[][] = [];
for (const [index, source] of result.sources.entries()) {
const sourceLabel = `Source ${index + 1}`;
const memberships = new Set<string>();
for (const value of (source.policyValues ?? []) as GpoPolicyValue[]) {
const signature = policySettingValueSignature(value);
if (!signature) return undefined;
let variant = variants.get(signature);
if (!variant) {
const displayed = value.kind === 'registry-policy' ? value.data : value;
const code = 'typeCode' in value ? value.typeCode : undefined;
variant = { label: `Variant ${variants.size + 1}`,
value: formatPolicyComparisonValue(JSON.stringify(displayed ?? value)),
type: code === undefined ? value.kind : `${registryTypes[code] ?? 'Unknown'} (${code})`, sources: new Set() };
variants.set(signature, variant);
}
variant.sources.add(sourceLabel);
memberships.add(variant.label);
}
if (!memberships.size) return undefined;
sourceRows.push([sourceLabel, ...(!duplicate ? [[...memberships].join(', ')] : []), source.sourceLocator?.objectPath ?? '',
source.policyBackupInstanceId ?? '', source.sourceResultId]);
}
if (duplicate ? variants.size !== 1 : variants.size < 2) return undefined;
const groups = [...variants.values()];
const differentValues = new Set(groups.map(group => group.value)).size > 1;
const differentTypes = new Set(groups.map(group => group.type)).size > 1;
const differences = differentValues && differentTypes ? 'Values and types differ' : differentTypes ? 'Types differ' : 'Values differ';
const firstValue = result.sources[0].policyValues?.[0] as GpoPolicyValue | undefined;
const context = [`Setting: ${result.title}`, `Scope: ${result.scope}`,
firstValue?.kind === 'registry-policy' ? `Registry path: ${firstValue.key}\nValue name: ${firstValue.valueName || '(Default)'}` : '']
.filter(Boolean).join('\n');
return { version: 1, summary: duplicate ? `1 setting · ${sourceRows.length} sources. Identical value and type.`
: `${groups.length} variants · ${sourceRows.length} sources. ${differences}.`, context, tables: [
{ name: duplicate ? 'Duplicate setting' : 'Conflicting variants', headers: [...(!duplicate ? ['Variant'] : []), 'Value', 'Type', 'Sources'],
rows: groups.map(group => [...(!duplicate ? [group.label] : []), group.value, group.type, String(group.sources.size)]),
emphasis: duplicate ? [] : [...(differentValues ? ['Value'] : []), ...(differentTypes ? ['Type'] : [])] },
{ name: 'Source references', headers: ['Source', ...(!duplicate ? ['Variant'] : []), 'Source path', 'Backup', 'Record'], rows: sourceRows, emphasis: [] },
] };
}
const cache = new WeakMap<FindingRecord, DatasetSheet[]>();
/** Validate the locally projected envelope as untrusted input; render cells only as text. */
export function policyEvidenceResultSheets(record: FindingRecord): DatasetSheet[] {
const cached = cache.get(record);
if (cached) return cached;
const fields = record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS].includes(field.label) && field.value);
if (fields.length !== 1 || fields[0].value.length > 10 * 1024 * 1024) return [];
const duplicate = fields[0].label === POLICY_DUPLICATE_DETAILS;
try {
const detail: unknown = JSON.parse(fields[0].value);
if (!detail || typeof detail !== 'object' || !('version' in detail) || detail.version !== 1
|| !('context' in detail) || typeof detail.context !== 'string'
|| !('tables' in detail) || !Array.isArray(detail.tables) || detail.tables.length !== 2) return [];
const sheets: DatasetSheet[] = [];
for (const [index, table] of detail.tables.entries()) {
if (!table || table.name !== (index === 0 ? duplicate ? 'Duplicate setting' : 'Conflicting variants' : 'Source references')
|| !Array.isArray(table.headers) || table.headers.length < 1 || table.headers.length > 8
|| !table.headers.every((header: unknown) => typeof header === 'string')
|| !Array.isArray(table.rows) || !table.rows.length || table.rows.length > 20_000
|| !table.rows.every((row: unknown) => Array.isArray(row) && row.length === table.headers.length && row.every(cell => typeof cell === 'string'))
|| !Array.isArray(table.emphasis) || !table.emphasis.every((column: unknown) => column === 'Value' || column === 'Type')
|| (duplicate && (table.emphasis.length > 0 || (index === 0 && table.rows.length !== 1) || (index === 1 && table.rows.length < 2)))) return [];
const rows = [table.headers, ...table.rows];
const records = table.rows.map((row: string[], rowIndex: number) => {
const result = buildRecord({ headers: table.headers, row, index: rowIndex, sheetName: table.name, sourceFile: record.id });
// Exact whitespace can itself explain a conflict. Do not normalize evidence cells.
result.fields = table.headers.map((label: string, cell: number) => ({ index: cell, label, value: row[cell] }));
result.filled = result.fields.filter(field => field.value !== '');
return result;
});
const sheet: DatasetSheet = { id: `${record.id}:gpo-${duplicate ? 'duplicate' : 'conflict'}:${index}`, name: table.name, rows, raw: '',
toolId: 'group-policy', sourceSheet: table.name,
analysis: { headerIndex: 0, headers: table.headers, records, columns: [], signals: [], title: '' } };
sheet.resultEmphasisColumns = table.emphasis;
if (index === 0) sheet.resultPreamble = detail.context;
sheets.push(sheet);
}
cache.set(record, sheets);
return sheets;
} catch { return []; }
}
/** Compatibility entry point for existing conflict consumers. */
export function policyConflictResultSheets(record: FindingRecord): DatasetSheet[] {
return record.fields.some(field => field.label === POLICY_CONFLICT_DETAILS && field.value) ? policyEvidenceResultSheets(record) : [];
}

Some files were not shown because too many files have changed in this diff Show More