Abtrennung zu neuem Datenmodell

This commit is contained in:
Michael 2026-09-21 09:10:54 +02:00
parent 03ea022f08
commit cdd93fe9e8
252 changed files with 52641 additions and 1547 deletions

View File

@ -20,23 +20,14 @@ cp -r assessor-ui ~/.claude/skills/
## Einbindung im Projekt
Die Token- und Komponentendateien sind nicht nur Dokumentation, sondern die Implementierungsbasis:
```bash
cp .claude/skills/assessor-ui/assets/tokens.css src/styles/tokens.css
cp .claude/skills/assessor-ui/assets/components.css src/styles/components.css
```
Einstiegspunkt des Stylesheets:
```css
@layer tokens, base, shell, components, utilities;
@import "./styles/tokens.css";
@import "./styles/base.css";
@import "./styles/shell.css";
@import "./styles/components.css";
```
Nutzerkorrektur 2026-09-18: Data Hub, Work und Pathfinder sind die verbindlichen
Ableton-inspirierten Referenzen, auch fuer Client Info, Mockups und Prototypen.
Zuerst [AGENTS.md](../../../AGENTS.md#binding-assessor-design-language) und den
Vorrangabschnitt in `SKILL.md` lesen. Die aktuellen Projekt-Tokens liegen in
`src/styles/ui-tokens.css`; bestehende Komponenten und Styles werden weiterverwendet.
Die Dateien unter `assets/` und aeltere Referenzskizzen sind historische Beispiele.
Sie duerfen nicht ueber die aktuellen Styles kopiert oder erneut in den Build
eingebunden werden. Die fruehere Kopieranleitung ist hiermit ausdruecklich aufgehoben.
Theme und Dichte am Wurzelelement:
@ -63,7 +54,7 @@ Erlaubte Werte: `data-theme` = `dark` oder `light`, `data-density` = `compact`,
Der Skill triggert automatisch bei UI-Aufgaben an ASSESSOR. Erzwingen laesst er sich mit:
```text
Nutze den Skill assessor-ui. Baue die Datahub-Uebersicht auf Archetyp D um.
Nutze den Skill assessor-ui. Gestalte Client Info mit der bestehenden Designsprache von Data Hub, Work und Pathfinder.
```
Vor dem Abschluss einer Aenderung:

View File

@ -16,6 +16,36 @@ description: >-
Verbindliches Designsystem fuer ASSESSOR. Keine Empfehlung, keine Inspiration: Vorschrift.
## Verbindliche Referenzen und Vorrang, Nutzerkorrektur 2026-09-18
Die aktuell vom Nutzer freigegebenen Ansichten **Data Hub, Work und Pathfinder**
sind die konkrete Ableton-inspirierte ASSESSOR-Referenz. Sie gelten ausdruecklich
auch fuer **Client Info**, einschliesslich Source Overview, Environment Detail und
Accepted Risks, sowie fuer Bildentwuerfe und Prototypen. Die drei am 2026-09-18
verworfenen generischen Client-Info-Mockups sind keine freigegebenen Vorlagen.
Massgeblich sind [AGENTS.md](../../../AGENTS.md#binding-assessor-design-language),
die aktuellen Komponenten und [ui-tokens.css](../../../src/styles/ui-tokens.css).
Die fruehere Client-Info-Designausnahme ist aufgehoben. Fachliche Inhalte,
Scoresemantik und Drilldowns bleiben erhalten; ihre Darstellung folgt denselben
Flächen-, Tile-, Button-, Typografie-, Abstands- und Zustandsregeln wie die Referenzen.
Diese datierte Festlegung hat Vorrang vor allen aelteren Beispielen und Tabellen
in diesem Skill, seinen `references/` und `assets/`. Insbesondere sind eine violette
Auswahl/Primaeraktion, kleine Fliesstextrollen, starre gekuerzte Tiles, dekorative
Donutschatten/Verlaeufe, ein erzwungener Tabellenumbau von Data Hub und eine neue
Shell keine aktuellen Vorgaben. Die aktuelle gelb/bernsteinfarbene Markierung und
Aktionsfarbe, der blaue Tastaturfokus, Inter, die 16px-Lese- und 14px-Datenrolle,
20px Panel-Inset sowie flache Tiles und volle 360-Grad-Donuts sind verbindlich.
Bestehende runde Hauptpanels behalten ihre gemeinsame Geometrie; die Tiles darin
bleiben eckig. Historische Skill-Assets niemals ueber die aktuellen Styles kopieren.
Vor jeder Abnahme ist der Entwurf bei normalem App-Zoom direkt mit Data Hub, Work
und Pathfinder abzugleichen: Er muss auch ohne Logo wie dieselbe Anwendung wirken.
Mockups verwenden nur eindeutig synthetische Beispieldaten und die echten
Referenzansichten als Stilvorlage; sie erfinden weder Kennzahlen noch eine neue
Navigation. Verankerung, Vorschlag und tatsaechliche UI-Umsetzung getrennt benennen.
## Zweck
ASSESSOR ist ein Analysewerkzeug fuer Active-Directory-Sicherheitsbefunde aus PingCastle,
@ -28,17 +58,17 @@ Loesung erfindet. Genau das hat den heutigen Zustand erzeugt: `Work` sieht ander
## Ablauf bei jeder UI-Aufgabe
1. `assets/tokens.css` und `assets/components.css` lesen. Beide sind die Implementierungsbasis.
2. Betroffenen Seitenarchetyp bestimmen (siehe `references/page-archetypes.md`).
1. Die aktuellen UI-Vereinbarungen in `AGENTS.md`, `src/styles/ui-tokens.css` und die betroffenen bestehenden Komponenten lesen. Die Skill-Assets sind historische Referenzen, keine zu kopierende Implementierungsbasis.
2. Die freigegebene Referenzansicht und das bestehende Layout bestimmen. `references/page-archetypes.md` nur ergaenzend unter dem Vorrang der Nutzerkorrektur vom 2026-09-18 verwenden.
3. Pruefen, ob eine bestehende Komponente ausreicht. Neue Komponente nur, wenn keine Variante passt.
4. Implementieren, ausschliesslich mit Tokens und bestehenden Klassen.
5. Gegen `references/review-checklist.md` pruefen, in Dark UND Light.
5. Direkt gegen Data Hub, Work und Pathfinder sowie die aktuellen AGENTS-Regeln pruefen; fuer Implementierungen in Dark UND Light. `references/review-checklist.md` unter ihrem datierten Vorranghinweis verwenden.
6. Abweichungen benennen, nicht stillschweigend loesen.
## Referenzdateien
- `assets/tokens.css` — alle Farben, Masse, Radien, Schatten, Typografie. Einzige Quelle.
- `assets/components.css` — Referenzimplementierung aller Kernkomponenten.
- `src/styles/ui-tokens.css` (Projektwurzel) — aktuelle Farben, Masse und Typografierollen.
- `assets/tokens.css` und `assets/components.css` — historische Beispiele; bei Abweichungen gelten ausschliesslich die aktuellen Projektvertraege und Implementierungen.
- `references/state-model.md` — Zustandsdimensionen und ihre Darstellung. Vor jeder Badge-/Statusarbeit lesen.
- `references/components.md` — Komponentenvertraege mit festen Massen und Aufbau.
- `references/page-archetypes.md` — Shell und die vier erlaubten Seitenlayouts.
@ -93,7 +123,9 @@ Diese Regeln gelten ohne Ausnahme. Ein Verstoss ist ein Fehler, keine Stilfrage.
- Buttonform ist verbindlich und ueberall dieselbe, unabhaengig von der Farbe: `--btn-radius`
(abgerundete Ecken), `--btn-h` als Hoehe, `--btn-pad-x` als waagerechtes Polster, senkrecht
kein eigenes Polster. Keine Ansicht setzt eine eigene Buttonhoehe, einen eigenen Radius oder
ein eigenes Polster. Ausgenommen sind nur zusammengesetzte Reiter (View-Tabs, Sidebar).
ein eigenes Polster. Auch zusammengesetzte Reiter, Sidebar-Aktionen, Icon-, Disclosure- und
Segment-Schaltflaechen verwenden den gemeinsamen Radius. Echte Kreis-/Donut-Interaktionen und
quadratische Inhaltskacheln behalten ihre semantische Geometrie.
- Traegt eine Schaltflaeche Farbe, ist es `--button-accent` (Standardgelb `#E9A63C`, in beiden
Skins gleich) mit `--button-accent-text`. Nie ein anderes Gelb, nie der Ton des Command Bands.
- Rahmen: 1 px. 2 px ausschliesslich fuer Auswahl- und Fokusrahmen.
@ -103,8 +135,16 @@ Diese Regeln gelten ohne Ausnahme. Ein Verstoss ist ein Fehler, keine Stilfrage.
- Schatten: nur `--shadow-overlay` und `--shadow-popover`, nur an echten Overlays.
Kein Schatten an Panels, Tiles, Zeilen, Buttons, Tabellen.
- Kein Glassmorphism, kein Neumorphismus, kein Farbverlauf als Flaeche, kein Hover-Lift, kein Scale-Effekt.
- Der Akzent (Violett) bedeutet ausschliesslich: Primaeraktion, aktive Ansicht, Auswahl.
Nie Severity, nie Erfolg, nie Markierung, nie Dekoration.
- Violett ist als UI-Rolle vollstaendig entfernt (Nutzerentscheidung 2026-09-20, AGENTS.md `UI segments and tiles`).
Aktive Ansicht, Primaeraktion und Auswahl sind `--marking-color` (Gelb/Bernstein), Tastaturfokus ist
`--focus-ring` (Blau). Kein Token, keine Komponente und kein Graph-/Chart-Ton darf violett aufloesen
oder `--accent*` fuer Auswahl oder Fokus lesen. Eine Tool-/Kategoriefarbe (z. B. ein Chart-Ton) braucht
bei Bedarf einen eigenen, dediziert nicht-violetten Tokenwert statt der fruesheren `--accent`-Wiederverwendung.
- Jeder Farbtoken hat genau eine semantische Rolle (Markierung, Fokus, Severity, Kategorie/Chart-Ton, ...).
Ein Token wird nie in einer Komponente fuer seine dokumentierte Rolle und in einer anderen Komponente
fuer eine zweite Rolle gelesen — das ist exakt der Fehler, der die violette Auswahl/Fokus-Verwechslung
von 2026-09-20 verursacht hat. Vor dem Wiederverwenden eines bestehenden Tokens dessen dokumentierte
Rolle in `ui-tokens.css` und in diesem Skill pruefen.
### Zustaende

View File

@ -1,5 +1,13 @@
# Shell und Seitenarchetypen
> Nutzerkorrektur 2026-09-18: Dieses Dokument beschreibt historische Layoutbeispiele.
> Massgeblich sind die heute freigegebenen Data-Hub-, Work- und Pathfinder-Ansichten
> und [AGENTS.md](../../../../AGENTS.md#binding-assessor-design-language), auch fuer
> Client Info. Weder die nachfolgende 18px-Topbar-Skizze noch Archetyp D autorisieren
> einen Umbau der aktuellen Shell oder den Ersatz der freigegebenen Data-Hub-Tiles
> durch eine Tabelle. Client Info verwendet dieselbe vorhandene Shell und dieselben
> Komponentenregeln; seine fachliche Anordnung darf innerhalb dieser Sprache variieren.
Es gibt eine Shell und vier Archetypen. Jede Seite von ASSESSOR ist genau einem Archetyp zugeordnet.
Eine fuenfte Seitenform wird nicht erfunden.

View File

@ -1,5 +1,31 @@
# Abnahme und Korrekturliste
## Verbindlicher Abgleich seit 2026-09-18
Die Nutzerreferenzen Data Hub, Work und Pathfinder sowie
[AGENTS.md](../../../../AGENTS.md#binding-assessor-design-language) haben Vorrang
vor der historischen Checkliste unten. Das gilt auch fuer Client Info und Mockups.
Insbesondere sind die frueheren Donutschatten-/Radiusausnahmen, der Tabellenumbau
von Data Hub, gekuerzte Tile-Inhalte, die alte Titelskala und violette Auswahl keine
gueltigen Abnahmekriterien. Bewusst freie Restflaeche in einer unvollstaendigen
Tile-Reihe ist zulaessig; Tiles werden nicht zum Fuellen der Breite gestreckt.
- Ist die Ansicht ohne Logo als dieselbe Anwendung wie alle drei Referenzen erkennbar?
- Entsprechen neutrale Flaechen, eckige Tiles, transparente Segmente, Linien, Controls,
Inter-Typografie und 20px Panel-Inset den gemeinsamen aktuellen Tokens?
- Bleiben gelb/bernsteinfarbene Auswahl/Aktionen, blauer Tastaturfokus und fachliche
Statusfarben unterscheidbar? Keine neue Palette oder neue Shell fuer Client Info.
- Verwenden Donuts volle 360 Grad, flache Bogenenden und korrekt zentrierte Zahlen?
- Sind Lesetext und strukturierte Daten bei normalem Zoom in ihren 16px-/14px-Rollen
lesbar, ohne Clamping, Ellipsen, Ueberlagerung oder eine Wand aus Mikroschrift?
- Behalten Kennzahlen, Source Overview und Drilldowns ihre dokumentierte Bedeutung?
Mockups kennzeichnen synthetische Daten; verworfene Bilder sind keine Stilreferenz.
- Bei Umsetzung: beide Themes, responsive Umbrueche und betroffene Interaktionen
pruefen. Reine Dokumentationsverankerung ist keine visuelle Laufzeitabnahme.
Die folgenden Abschnitte bleiben als historische Referenz erhalten; widersprechende
Punkte werden durch die obigen Kriterien und AGENTS.md ausdruecklich ersetzt.
## Inhaltsverzeichnis
1. Automatisierbare Pruefungen

109
AGENTS.md
View File

@ -9,6 +9,13 @@
- After a material, verified implementation step, update the plan's status, decisions, risks, and change log in the same change. Do not rewrite historical decisions silently.
- If the requested implementation conflicts with the plan, explain the conflict and update the plan only with the user's agreement or when the request explicitly supersedes it.
## Binding agreements and rule conflicts
- Treat documented user decisions, this file, and the implementation plan as binding acceptance criteria. Before changing an affected behavior, identify and preserve every relevant agreement; later work must not silently weaken, replace, or bypass it.
- If a requested change conflicts with a binding project rule, security boundary, or documented decision, stop that conflicting part and tell the user which rule blocks it and why. Do not implement a quiet approximation. Change the governing rule only after the user explicitly agrees, or when the request explicitly supersedes it.
- Every regression fix for a documented agreement must add a synthetic behavioral test for the exact user interaction that regressed, not only a lower-level helper or adjacent route. Keep that test as a permanent acceptance check.
- When requirements appear ambiguous, distinguish the supported interaction paths explicitly. Do not claim an agreement is fulfilled when only one path satisfies it and another normal path exposes different behavior.
## Architectural boundaries
- Keep format parsing, structural analysis, semantic mapping, normalization, comparison, persistence, and presentation separated.
@ -27,14 +34,25 @@
- `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.
## Binding ASSESSOR design language
- Explicit user correction 2026-09-18: the current, user-approved Data Hub, Work, and Pathfinder screens are the binding visual references for ASSESSOR's Ableton-inspired design language. This applies to every view, explicitly including Client Info, and to proposals, generated images, prototypes, and production implementation.
- Before any visual task, read `.claude/skills/assessor-ui/SKILL.md` and the relevant current components and `src/styles/ui-tokens.css`. The approved views and these current project agreements take precedence over older skill examples, copied asset templates, and superseded Client Info exceptions.
- Use Data Hub for flat square tiles, full-circle donuts, compact action buttons and branding; Work for list/detail hierarchy, typography, badges and reading surfaces; Pathfinder for stable filter/work/detail zones and compact controls. Preserve these approved views as references; do not redesign them as a side effect of Client Info work.
- Keep the current neutral theme surfaces, transparent segments, flat tiles, subtle separators, local Inter typography, shared 20px panel inset and shared control geometry. Reuse existing tokens and components. Do not reinterpret Ableton as a new palette, a literal copy of another application, or a return to obsolete UI dimensions.
- Use the shared yellow/amber roles for primary actions and persistent selection, blue for keyboard focus, and semantic status colors for the established status and data-visualization roles. A tool or graph-category color does not authorize a new selection or button color. No gradients, decorative shadows, glass effects, hover lift, generic rounded dashboard cards, new navigation shell, new logo, or Client-Info-only visual theme.
- Improve quick comprehension through grouping, alignment, readable labels, a small set of relevant overview values and existing detail/disclosure patterns. Never shrink body text or structured data below their agreed roles to fit more content, stretch sparse tiles across unused space, or invent metrics to populate a layout.
- All mockups must use the approved views as actual visual references, preserve the existing shell and metric meanings, and clearly identify synthetic example data. A rejected generated image is not a design reference. The three generic Client Info mockups rejected on 2026-09-18 are explicitly not approved.
- Visual acceptance: a Client Info proposal or implementation must be recognizable as the same application as Data Hub, Work and Pathfinder even without its logo. Check the shared surfaces, typography, buttons, selection, spacing and donut geometry at normal application zoom; inspect both themes for implementation. Distinguish a documented rule, a proposal and an implemented/verified UI change.
## UI typography
- 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.
- Use the semantic typography roles: H1 56px/600, H2 32px/600, H3 18px/600, detail titles 20px/600, H4 16px/600, continuous prose and readable tile values 16px/400, structured table/Grid/Raw Data content and informational field labels 14px/400, compact control labels and 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.
- Render continuous prose such as finding and report text at no less than `16px`. Structured data in tables, Grid, and Raw Data uses the dedicated compact 14px role.
- Compact interface text such as button captions, field labels, metadata, badges, and comparable control annotations may use a smaller type size.
- Explicit user decision 2026-09-20: the smallest text intended to communicate application information is `14px`; this includes table cells, table headings, tile content and informational field labels. Readable values and continuous prose use `16px`. For example, the Client Info Directory tile renders `Domain` at 14px and `sevenkingdoms.local` at 16px. Do not demote either role to a metadata or compact-control token merely to fit more content.
- Compact interface text such as button captions, technical metadata, badges and comparable control annotations may use a smaller type size. These exceptions must not carry primary table, tile, field-label or prose information.
- When adding or changing styles, preserve this distinction instead of reducing content text to a compact UI-label token.
## UI panel spacing
@ -44,36 +62,105 @@
- Apply the inset exactly once on the view's root content container. Split panes and edge-to-edge structures belong inside that inset and must not add a second outer inset.
- Keep the 20px outer inset at responsive breakpoints unless the user explicitly changes this standard.
## UI Work filter context
- Explicit user extension 2026-09-18: the Work toolbar's `CSV`, `MD` and `HTML` buttons export the complete current filtered/sorted Work record set, including the same linked and embedded Results used by the Results window. Marking alone does not select export rows; `My Workspace` restricts them only when active, alongside ordinary filters and drilldowns. Use exactly two content columns, `Soll-Zustand` and `Abweichungen`, with source-backed remediation/verification, original technical facts, scoped GPO provenance, and explicit missing-evidence notices. Preserve neutral verdicts and review-versus-rescan boundaries. Build the frozen projection only on export, not on navigation. Oversized CSV cells use explicitly identified continuation rows of the same finding without truncation. This view snapshot is separate from the curated Arbeitsgrundlage in `docs/REPORT_STRATEGY.md`.
- A unique `Source: Scan` selection is a semantic tool context even when Work remains on the project-wide `All Records` sheet. Visible and active facets must follow the selected tool context rather than the aggregate sheet profile.
- For a GPO-only Work context, whether reached through the dedicated GPO sheet or `Source: Scan = GPOs`, show only `Result Status`, the available GPO facets `Baseline`, `Comparison`, `Conflict`, `Description source`, `Diagnostics`, `Duplicates`, `Imported`, `Policy section`, `Policy type`, and the permanent final `Source: Scan` control. Do not show CIS, severity, category, MITRE, target, result-message, review, or scanner-specific facets.
- Explicit user correction 2026-09-17 supersedes only the category exclusion in the preceding GPO-context rule: `Category` is a permanent GPO Work filter in both supported entry paths, including when its only current value is `GPOs`. Keep it directly after `Result Status`. Severity, CIS, MITRE, target, result-message, review and scanner-specific facets remain excluded from the GPO-only context; `Source: Scan` remains last.
- Explicit user extension 2026-09-17: Work also exposes a distinct `Security Domain` facet immediately after `Category`. It must use the exact shared Client Info security-domain catalog and record assignment, never a second filter-only categorization. It is available in mixed Work and both GPO entry paths; a pure GPO scope maps to `GPO & Baseline`. Domain selection and Client Info drilldown must therefore select the same record identities, while ordinary active Work filters continue to apply. `Source: Scan` remains last.
- Explicit user correction 2026-09-18: `Security Domain` always offers all eight Client Info catalog entries in catalog order, including selectable zero-hit domains in mixed Work and both GPO entry paths. `Unclassified` remains an additional option only when present in the loaded sheet. Counts respect the other active filters; a zero-hit selection must not silently remove itself, the active status, or another still-valid facet selection. A Client Info domain-deviation drilldown uses this visible domain facet rather than a fixed record-ID path, so the user can change domains in Work. Explicit review-only and individual-finding paths retain their separate scope semantics.
- Explicit user decision 2026-09-17 supersedes the previous six-value list: the GPO `Result Status` choices are exactly `All`, `Deviation`, `Passed`, `Not configured`, `No baseline rule`, `Not comparable`, `Conflicts`, and `Duplicates`. Keep the seven non-All choices selectable at zero hits; never merge scanner-only status vocabularies into a GPO-only context. `Deviation`, `Passed`, `Not configured`, `No baseline rule` and `Not comparable` match the authoritative rolled-up policy verdict; `Conflicts` and `Duplicates` match the corresponding diagnostic category flags. One policy setting may therefore belong to both `Deviation` and `Conflicts` or `Duplicates`.
- Explicit user decision 2026-09-17: `No baseline rule` is the score-neutral verdict for a retained customer setting whose resolved technical identity has no applicable Microsoft baseline recommendation in the compared baseline set. Every aggregated customer state must reach Work under exactly one verdict: an evaluated verdict where a baseline rule exists, otherwise `No baseline rule`. It is distinct from `Not configured`, which is a recommended baseline setting absent from the analyzed backup set, and it must never be presented as a deviation, remediation obligation or score input. A diagnostic `Conflicts`/`Duplicates` flag marks such a setting without replacing its verdict; unresolved identities stay out of this verdict and take `Not comparable` instead.
- Explicit user decision 2026-09-17: `Not comparable` is the score-neutral verdict for a retained customer setting whose delivery mechanism has no comparable technical policy identity. Today this is always a Group Policy Preference. Never resolve a preference into a policy identity and never compare it with a Microsoft baseline recommendation: its action, item-level targeting and apply-once semantics differ from a policy setting, so a baseline verdict over it would be invented. Keep the retained CSE, action and item-level targeting readable in the agreed GPO columns; do not add a GPO Work column or facet for them. Identical or differing preference instructions are not proven duplicates or conflicts, so `Not comparable` carries no diagnostic flag. The observation-level unresolved-identity gap reporting stays in place and must report each observation once with its authoritative resolver reason, never restated a second time by snapshot aggregation.
- Never infer a GPO verdict from comparison prose, historical results, baseline text, guidance or evidence. Use only the authoritative rolled-up policy verdict and explicit conflict/duplicate flags. A foreign `Pass` string must not make a not-configured, review or failed policy setting match `Passed`.
- When the selected tool context makes an existing facet filter invalid, remove that incompatible filter so it cannot remain invisibly active. Removing the tool restriction restores the appropriate aggregate facets without changing source data.
- Explicit user extension 2026-09-17: also offer `GPO backups with failing settings` in the GPO Work context. Its all-counted and individual-backup selections use the Processing tile's exact project/assessment/backup-instance identity and select source-linked Fail comparisons, not inferred per-backup verdicts. Keep all contributing evidence, conflict/duplicate flags and historical evaluation readiness intact; Source: Scan remains last. This extends the facet list above.
## UI Result DB filter consistency
- Explicit user correction 2026-09-20: in linked CIS-CAT Results evidence, an evaluated source row must follow the actual finding outcome. Highlight the confirmed configured row with the shared green match treatment when the finding outcome is `Pass`, and use the red violation treatment only when the outcome is `Fail`; unknown, neutral or unavailable outcomes must not be guessed into either state. The criterion row remains the separately identified expected state.
- Explicit user correction 2026-09-18: Result DB uses the shared Work vocabulary for its GPO `Result Status` filter and details: `Deviation`, `Passed`, `Not configured`, `Conflicts`, and `Duplicates`; `Review required` remains an independent unresolved result. Stored classification/query values stay unchanged. Status facet counts must honor all other active filters, including Deviation; only their own status selection is excluded to keep alternatives reachable. Selecting or clearing a status must never silently reset Deviation. Selected zero-hit values stay visible and removable. This supersedes the earlier Result DB exception that ignored and automatically cleared Deviation. Result DB still counts individual comparisons, while Work counts rolled-up settings; no extra public observation types or invented statuses are introduced.
## GPO Registry baseline comparison
- Treat retained `Registry.pol` `delete-value`, `delete-all-values`, and `create-key` instructions as explicit, comparable target states rather than unavailable baseline values. The comparison remains scoped to imported backup configuration and must never claim effective endpoint registry state.
- A delete-value requirement passes when that value is absent from the analyzed backup set or explicitly deleted, and fails when the value is set. A delete-all-values requirement passes only when no value is configured below that key or the delete-all instruction is explicit; any retained configured child value fails it. A create-key requirement passes when the key is explicitly created or a retained configured child proves its presence, and fails when the key is absent.
- A typed empty `REG_SZ` or `REG_EXPAND_SZ` is a present value. Compare its registry type and exact empty content; never convert it to missing/unavailable. Missing required presence is Fail, while ordinary missing value recommendations remain score-neutral `Not configured`.
- Preserve contributing child-state IDs and observation provenance for key-level comparisons. Unsupported Registry.pol special operations remain `Review required`; do not guess their semantics.
## GPO analysis and scoring scope
- Explicit user decision 2026-09-17: the Finding Reader must immediately show contributing GPO backup instances and attribute every retained operation/value to its source, including deletion instructions. Group by exact project/assessment/backup-instance identity, not title, registry value or observation count; retain source-local sequence and never present different GPOs as one execution chain. Show retained native GPO/backup IDs; do not infer missing friendly names from paths. This inline source attribution supersedes any earlier Results-only hiding of these sources, while Results remains available for variant comparisons.
- Explicit user decision 2026-09-17: render that inline attribution as one flat table with the columns `GPO source`, `Operation`, `Data`, `Type`, and `Evidence`, one row per exact backup identity, a compact status marker, and expandable `Show source details`. The expansion retains full GPO/backup identity, source-local sequence, safe evidence reference, key, value name and every original instruction. Bold Operation and Data. Mark `Conflicting contribution` only when the retained current baseline variants independently prove that single source member differs; mark alignment only when every retained variant independently matches. Do not derive source markers from historical evaluations, multiple instructions inside one backup, GPO precedence or endpoint effectiveness.
- Explicit user correction 2026-09-17 supersedes the flat table presentation above: show each exact GPO backup identity again as a complete, vertically stacked source section with Evidence, source-local sequence and every original instruction immediately visible. Use a restrained red background and red leading accent for a source section only when the retained current baseline variants independently prove that single source member differs. Keep aligned, unassessed and ambiguous sections neutral; retain the same historical, precedence and endpoint-effectiveness boundaries.
- Explicit user correction 2026-09-17 supersedes the whole-section red treatment above: keep every GPO source section neutral and apply the restrained red background only to the exact displayed field value proven different from every retained current baseline variant. Highlight `Operation` for an operation mismatch, `Data` for a data mismatch and `Type code` for a registry-type mismatch; several fields may be marked only when each is independently proven. Do not highlight a whole source, heading, path, key, value name or conflict badge, and do not guess a field when variants disagree about the cause.
- Explicit user extension 2026-09-17: every proven GPO source-value deviation must receive the same restrained red value treatment. Keep registry-policy differences field-specific for `Operation`, `Data` and `Type code`, including simultaneous differences. For other comparable GPO value kinds, or when all retained variants prove a difference but no one field is the common cause, mark the displayed source value as a whole without asserting a narrower cause. Review-required, unassessed and aligned values remain neutral; never color the whole source section, heading, evidence path or diagnostic badge.
- Explicit user correction 2026-09-17: a principal-set is a collection comparison, not one indivisible value block. Mark only configured principals absent from every retained current baseline variant. A principal also present in the expected baseline remains neutral even when neighboring principals deviate. If the deviation consists only of a missing expected principal, do not color an existing configured principal as its substitute; the expected-baseline block remains the evidence for that absence.
- For versioned member comparisons, derive the Conflict flag from the retained authoritative customer state, not from a set-value-only subset of original instructions. Reduced-identical contributions from multiple backups may be Duplicate; multiple earlier instructions inside a single backup are not duplicate GPOs or independent current conflicts. Keep these diagnostic flags separate from the policy verdict and preserve historical evaluation readiness.
- An additional indeterminate GPO must not hide an independently proven difference between other GPO backups. Retain that diagnostic only when distinct, unambiguous backup contributions of the same technical setting prove different supported signatures also present in retained compared values. Exclude the whole uncertain backup from that proof; its earlier writes may remain in retained values and must not be mistaken for separate GPOs. The overall verdict remains Review required.
- The operational GPO assessment is misconfiguration-first and limited to customer settings that are actually configured, technically comparable and in the explicitly applicable Microsoft-baseline scope. Do not turn every recommended but absent baseline setting into an operational finding, remediation obligation or score penalty. A complete-baseline rollout is a separate, explicitly commissioned hardening track.
- `Not configured` remains a score-neutral policy verdict. `Duplicates` and `Conflicts` remain visible, mutually distinct diagnostic categories on a policy setting; they are not alternative verdicts that can suppress `Passed`, `Fail` or `Review required`.
- Roll member comparisons up once per technical policy-setting identity. If at least one independently comparable, in-scope configured member value is a valid `Fail`, the whole policy setting verdict is `Fail`, while its conflict/duplicate flags remain visible. If every applicable member passes, the policy verdict is `Passed`. If member applicability, precedence or comparison is unresolved, use `Review required` rather than inventing a fail. Source multiplicity, duplicate rows and diagnostic flags must never multiply the setting's score weight.
- Label the GPO score as conformance of configured, comparable in-scope settings. Never describe it as full Microsoft-baseline compliance, baseline coverage or proof that all recommended settings are implemented.
- Keep absent recommended settings available under `Not configured` for transparent inspection, but exclude them from Priority Findings, default remediation scope and score unless the user explicitly starts a separate baseline-hardening scope.
- `Not configured` is scoped to the analyzed GPO backup set: if an applicable baseline recommendation has no observed configured value in that set, keep it filterable as `Not configured` even when the import is a selected subset. Retain a scope-limit diagnostic and never turn this into a domain-wide or effective-device absence claim without complete collection evidence or RSoP/GP Results.
## UI segments and tiles
- Treat segments and tiles as a binding two-level visual grammar independent of their content. A segment is a transparent grouped layout; a tile is a flat, square-cornered surface inside it.
- Use only the shared `--segment-*` and `--tile-*` tokens for segment surfaces, borders, radii, insets, gaps, tile surfaces, shadows, widths, hover states, and selected contours. Do not create view-local substitute geometry.
- Segments are visually transparent and have no visible border or shadow. They retain `--segment-radius`, one uniform inset, and one uniform gap as shared layout geometry.
- Tiles use the common white or theme-equivalent surface without a visible normal-state border, radius, or shadow. Hover changes the surface only and must not add a decorative contour, lift, or translation.
- Explicit user correction 2026-09-20: in the light theme, tile surfaces use the shared neutral gray `--tile-surface` instead of pure or near white to reduce glare; the current approved value is `#F5F5F7`. Keep the surrounding app surface, input surface and established hierarchy distinct; do not reproduce this with view-local overrides.
- Status color belongs only in a small accent line, marker, data visualization, or selected contour. Never fill the complete tile with a status color.
- Keyboard focus and selected tiles use the shared functional focus or selected outline. Action tiles and add tiles use the same flat geometry as content tiles.
- Explicit user correction 2026-09-20: the persistent yellow outline of the selected Data Hub tenant tile is one hairline thicker than the shared 2px selection outline, composed from `--bw-emphasis` plus `--bw-hairline`. This is a scoped Data Hub tenant-selection exception; keyboard focus remains the separate shared blue 2px outline, and no other tile-selection width changes.
- The persistent marking and selection color is always the shared yellow `--marking-color` in both themes. Purple must not be used for selected contours or markers. Blue remains reserved for keyboard focus, while green, red, orange, and severity colors remain domain status signals rather than selection colors.
- Keep unused segment space intentionally empty: lay tiles out from the start at their shared compact or content width and never stretch a partial final row merely to fill the container.
- Explicit user correction 2026-09-20: the Data Hub result-donut row and the client/add-tile row below share the exact same six-column track geometry at wide widths. Every column start, width and gap must align vertically without cumulative horizontal offset; all six result donuts remain on one line. At narrower widths both rows return to the same compact responsive tile contract.
- Tile content must never cross the tile boundary or cover a neighboring tile. Nothing may be clipped, clamped, truncated, or replaced by an ellipsis. Text and actions reflow inside the tile; media scales within the available inner area. If content does not fit, enlarge or reflow the tile.
- Avoid line breaks in compact tile labels where practical. If wrapping is necessary, wrap only at word boundaries: never split ordinary words or numeric values across lines. Use a wider or responsive tile whenever a complete word or value cannot fit.
- Explicit user decision 2026-09-20: purple/violet is removed as a UI role entirely, not only as a selection color. The `--accent`/`--accent-hover`/`--accent-active`/`--accent-on`/`--accent-subtle` tokens in `src/styles/ui-tokens.css` must not resolve to a violet value and must not be read wherever the consuming rule means keyboard focus (use `--focus-ring`) or active/selected state (use `--marking-color` and its existing selection/marker roles). A tool- or graph-category tone (for example the PKI chart tone) that previously reused `--accent` gets its own dedicated, non-violet, non-focus, non-marking category color instead of being retargeted onto focus or marking roles. This closes the drift documented in Client Info/Work/Pathfinder screenshots reviewed 2026-09-20, where the same segmented-tab-selection component rendered amber in one view and violet in another because `--accent` was simultaneously documented (superseded `assessor-ui` skill text) as meaning both "primary action / active view / selection" and used in code for `:focus-visible`, conflicting with the yellow-marking/blue-focus rule above.
## UI donut charts
- Every donut chart uses the same full 360-degree geometry: a continuous neutral-gray track and a colored clockwise value arc starting at 12 o'clock.
- Donut arcs use flat ends and the shared `--donut-ring-stroke`, `--donut-track`, value, and typography tokens. Do not introduce 270-degree gauges, open gaps, rounded arc caps, or local substitute geometry.
- Place the primary numeric value itself on the exact visual center of the ring. Append a percent sign or other short unit to its right on the same baseline in the smaller shared UI size without letting that unit shift the number away from the center. Supporting counts may remain as subdued secondary text inside the ring, but every text element must stay fully within the clear inner area and must never touch or overlap the ring at any supported value width.
- Explicit user correction 2026-09-20: every count/percentage donut places the absolute count as the bold first line and the percentage as a regular-weight second line below it. Every donut readout, including score rings, keeps a visible safety gap from the inner edge of the ring at every supported ring size and value width. A score ring such as Overall Score retains its score-specific `/100` semantics instead of inventing an absolute count.
- Center the complete two-line readout as one unit inside the ring without letting either line shift horizontally. Keep every text element fully within the clear inner area and never let it touch or overlap the ring at any supported value width.
- Multi-segment distributions retain their categorical segments, but use the same track, stroke proportion, start position, centered value treatment, and shared tokens as single-value donuts.
## UI Client Info dashboard
- The loaded Client Info view is an explicitly scoped exception to the generic tile composition: `.client-command-center` uses the connected command-center treatment and dedicated accent roles defined by `--client-info-*`, while both its outer page background and every dashboard tile inherit the active theme exactly like Work via `--surface-app`, `--tile-surface`, `--tile-surface-hover`, and the corresponding shared text/line roles. This exception supersedes generic card composition only inside that root; it must never leak into Data Hub, Work, Result DB, Pathfinder, dialogs, or the application shell.
- Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter.
- Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity.
- Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is environment facts, overall posture, security domains, priority findings and assessment gaps, then source evidence.
- Keep environment facts in one compact, single-row evidence rail of icon-led dark tiles. They may be visually separated like the approved Client Info reference, but must remain substantially smaller than the posture and action areas below.
- Keep environment facts in a compact evidence rail, using the same neutral theme-aware tiles as Data Hub. Wrap at readable widths instead of forcing one tiny-text row. The former mandatory dark-tile treatment is superseded by the 2026-09-18 correction.
- Explicit user correction 2026-09-20 supersedes the separate evidence rail above: AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers and GPOs live inside the `Data` tile as one flat 3-by-2 fact grid, retaining their existing glyphs. Do not render a separate top-level Environment Facts tile row. The six facts are not nested tiles; use transparent cells and subtle separators inside `Data`.
- Present Overall Score and Security Domains together on one shared posture surface. Overall Score is the dominant visual anchor; domain scores form a visibly connected branch or spine beside it and must not look like a conventional data table.
- Explicit user correction 2026-09-20: at wide desktop widths, the posture surface and action rail each use exactly one half of the available row width. This supersedes the earlier two-to-one proportion. Every tile aligned in one row must always share the same height and bottom edge; never use intrinsic-height staggering within a row. This also supersedes the earlier instruction to keep the posture surface shorter than the action rail.
- Explicit user correction 2026-09-20: the top of the Client Info action rail is an exact 50/50 row. `Domain Information` sits on the left and `Priority Findings` on the right with identical height; `Assessment Gaps` spans the full action-rail width below. Domain Information contains every fact from the Environment Detail `Directory` group—Domain, NetBIOS name, Forest, Domains, domain functional level, forest functional level, schema version, domain created and Entra ID—plus the separately requested domain-controller count. All values come only from the same normalized Environment Snapshot. Missing facts remain an em dash and must never be inferred as zero.
- Explicit user correction 2026-09-20: the Overall Score block does not repeat a textual score status below the ring and does not repeat the critical-finding count already present in Priority Findings. Assessment Coverage uses at most 70% of the Overall zone and never exceeds the shared compact tile-content width; it must end before the curved domain connectors, which may never cross its label, bar or supporting count.
- Explicit user correction 2026-09-20: the Overall Score block also omits the secondary `Unassessed` and `Lowest domain pass rate` fields completely, including their divider container. `Unassessed controls` remains visible only in Assessment Gaps; domain pass rates remain visible on their individual Security Domain nodes.
- Explicit user decision 2026-09-18: preserve the thin curved fan connections from Overall Score to every displayed Security Domain during the Ableton-style alignment. They are a binding visual feature, not optional decoration. Derive their endpoints from the rendered ring and domain nodes and remeasure on resize/content changes. Preserve the existing stacked narrow-screen fallback; never keep desktop coordinates on a stacked layout.
- Explicit user correction 2026-09-20: Security Domain rows show the domain name in the shared detail-title role without the former passed/not-configured/duplicate/conflict/review metadata line underneath. The score node and right-hand deviation or review count remain. The Overall Score ring is a keyboard-accessible entry to Work's clean remediation default: clear prior search, source, domain, severity, record-ID and workspace filters, select `Deviation`, and open the standard Work overview.
- Explicit user correction 2026-09-20: Every displayed Security Domain row carries a subtle amount bar in its own shared grid column between the longest rendered domain label and the widest rendered amount. Keep the shared gap on both sides so a bar can never grow into text or behind a number. All bars share one right edge and extend left from that edge. The later color correction supersedes the preceding categorical-color decision: every amount bar uses the exact current semantic color of the Overall Score donut and changes with that score's critical, medium, passed, or unknown tone. Do not use selection, focus, per-domain score, or a separate palette. Bar width is that row's displayed deviation amount divided by the sum of the displayed domain amounts; review-only rows use their already-displayed review amount. Keep the exact right-aligned number visible as the authoritative value, make the bar decorative and hidden from assistive technology, and never change counts, scoring, classification, or drilldown semantics to produce the visualization.
- Explicit user correction 2026-09-20 supersedes the transparent Client-Info donut treatment above: the total `Deviations` donut in Client Info's `Data` tile uses the exact Data Hub `datahub-reference-knob` visual variant, including its tile surface, dimensions, spacing, title and hover state. Data Hub and Client Info derive its count and percentage denominator from the same selected mixed-timeslot project sheet, including assessment-bound GPO/policy observations as well as scanner records; never calculate the Data Hub value from scanner run IDs alone. The complete donut remains a keyboard-accessible action to Work with the `Deviation` filter.
- Explicit user extension 2026-09-20: Client Info's Operating systems table shows PingCastle's row-bound `Nb Active`, `Nb Inactive`, `Nb Enabled` and `Nb Disabled` counts beside `Nb OS`. Join each value only to the exact retained report, source table and row of that operating-system entry; never derive one state from another, borrow a value from a different row or add overlapping inventories. Missing values render as an em dash. Other scanners and older imports remain visible without invented state counts.
- Keep Client Info immediately scannable: Environment Detail starts collapsed with its evidence/restriction counts visible; the Accepted Risks master/detail summary remains visible, while its complete context, controls, approval, evidence and decision history are individually expandable. Disclosure must remain keyboard accessible and must not change source data, scoring, risk decisions or drilldown identity.
- Explicit user correction 2026-09-20: every Environment Detail tile heading, including Source Overview and Operating systems, carries one restrained existing ASSESSOR line glyph before its title. All fact groups reuse Source Overview's list spacing and row padding for one consistent, airy rhythm; fixed tile heights, the four-column grid and internal scrolling remain unchanged.
- Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow.
- Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow.
- Explicit user correction 2026-09-20: Accepted Risks reuses the Data Hub client-detail geometry one-to-one: a fixed compact selection rail at the left using `clamp(420px, 27vw, 520px)`, the information surface at the right, and the established stacked narrow-screen fallback. Do not reintroduce a proportional wide list column.
- Keep Priority Findings and Assessment Gaps in a dedicated action rail. Use status color only for finding markers, score arcs, score nodes, and progress bars; avoid large colored fills and nested card stacks.
- Source Overview is the final evidence rail. Explicit user decision 2026-09-10: show the actual versioned ASSESSOR score contribution as the primary number, using a run/file-bound native score when supported and a pass-rate fallback otherwise. Label the method; PingCastle uses the explicit `100 - native risk` convention and retains the raw risk as secondary context. Never insert a raw lower-is-better risk value directly into the overall scale. Follow `docs/SCORING_MODEL.md`; Security Domain values are pass rates, not a decomposition of the mixed-method index.
- Explicit user correction 2026-09-20 supersedes the standalone Source Overview rail: source scores live inside the expanded `Environment Detail` as one compact plain list with separators, never as tiles. Preserve every source's actual versioned ASSESSOR score contribution, method label, native context and Work drilldown. A run/file-bound native score takes priority when supported and pass rate is the fallback; PingCastle uses the explicit `100 - native risk` convention and retains raw risk as secondary context. Never insert a raw lower-is-better risk value directly into the overall scale. Follow `docs/SCORING_MODEL.md`; Security Domain values are pass rates, not a decomposition of the mixed-method index.
- Explicit user extension 2026-09-20 supersedes only the preceding container geometry: all expanded Environment Detail groups form an equal four-column tile grid. `Source Overview` occupies one group tile while its individual source entries remain the agreed plain separated list, never individual source tiles. The six fact groups and Operating systems each occupy one tile. Every tile is exactly three quarters of the currently rendered Overall Score tile height; derive that height from the rendered element and keep overflow inside the affected tile with a scrollbar. Preserve four columns on narrow layouts through horizontal grid scrolling rather than changing tile width or column count.
- Explicit user correction 2026-09-20: Source Overview never renders a `Tabular Data` tile. Generic tabular imports remain available through their supported data and filter paths, but they are not a security-scoring source tile.
- Unknown or unavailable facts render as an em dash and never as an inferred zero. Keep the dashboard English-only and omit explanatory prose that does not support an immediate first-meeting statement or action.
- The Client Info composition still obeys the shared Inter typography roles, donut geometry, marking semantics, and panel inset. Its dedicated palette, connected geometry, and compact briefing surfaces are isolated Client Info primitives rather than application-wide tile replacements.
- The content hierarchy, score semantics, source provenance and drilldowns remain binding. Their layout must use the shared ASSESSOR components and tokens; a connected briefing layout does not permit a dedicated palette or geometry. Source Overview, Environment Detail and Accepted Risks obey this same rule, including their collapsed, expanded and selected states.
## UI branding position
@ -81,6 +168,12 @@
- Keep the brand container geometry identical across every application view; do not add area- or view-specific brand padding, margin, alignment, height, or transforms.
- Switching views must never move the logo, wordmark, or version.
## UI backend connection state
- Data Hub, Result DB and DB Structure must present a checking or unavailable local backend through the shared ASSESSOR dialog rather than an embedded page banner.
- Keep that dialog dismissible so the user can inspect the underlying view. The application continues its existing automatic health checks; reconnecting removes the offline branch without requiring a reload.
- Keep the copy limited to the affected capability and the automatic connection check. Do not expose response bodies, project data or imported content in this technical connection state.
## Navigation and rendering performance
- Treat area switches as a performance-sensitive path. Measure the first visit and repeated visits separately after the workspace has loaded; distinguish main-thread rendering from asynchronous API readiness. Use the local procedure in `docs/architecture/navigation-performance.md`.

View File

@ -1,5 +1,25 @@
# Sicherheitsmodell
## Accepted Risk: dokumentierte externe Entscheidung
Ergaenzung 2026-09-10: Der lokale Accepted-Risk-Dialog speichert begruendete,
befristete Entscheidungen mit Verantwortlichen, separater Freigabedokumentation,
Massnahmen, Wiedervorlage und vollstaendiger Versionshistorie im bestehenden
Review-JSONB. Der eigene Same-Origin-Endpunkt prueft Projektzugehoerigkeit,
Pflichtangaben und erwartete Revision; die Repository-Transaktion haengt eine
serverdatierte Version an. Allgemeine Reviewaenderungen duerfen die vorhandene
Historie nicht ersetzen. Originalbefunde und technische Bewertungen bleiben erhalten.
Namen und Freigabereferenzen sind Angaben zu einer externen Entscheidung, keine
authentifizierten Signaturen. Das Formular verifiziert weder Personen noch
Nachweisinhalte oder Freigabeberechtigungen. Referenzen bleiben inerter Text;
es gibt keine neuen Uploads, Netzfreigaben oder externen Dienste. JSONB-Historie
und Revisionsschutz sind kein Schutz gegen Datenbankadministratoren und kein
unabhaengiges Aufbewahrungsarchiv. Bestehende Loeschregeln bleiben anwendbar.
SSO, Rollen, verbindliche Freigaberegeln und manipulationsnachweisbare Aufbewahrung
bleiben Voraussetzungen fuer den Firmenbetrieb in Phase 14. Details und die
geplante normalisierte Ablage stehen in [RISK_ACCEPTANCE.md](docs/RISK_ACCEPTANCE.md).
## Lokale Datenverarbeitung
HTML-Parser-Nachhaertung 2026-09-09: Attributnamen und -werte werden linear

View File

@ -3,6 +3,12 @@
Stand: 2026-07-17
Zugehörige Planphase: Phase 8
## Statusklarstellung 2026-09-11
Der folgende Text dokumentiert den historischen Stand, nicht die Abnahme eines belastbaren objektgenauen Timeslotvergleichs. Insbesondere ist die unten beschriebene Regel `resolved = im Ziellauf nicht mehr vorhanden` kein ausreichender Behebungsnachweis. Die aktuelle Timeslot-Projektion kann Quell-/Verarbeitungsprüfungen umgehen; die automatisch verwendeten Regel-/Definitionsmatches garantieren keine gemeinsame Objektidentität. Die hier historisch genannten Regel-/Asset- und Merkmalsstufen sind kein Nachweis ihrer aktuellen automatischen Verdrahtung.
Die Erweiterung in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) definiert den Gegenprüfungs-, Identitäts- und Fortschreibungsvertrag sowie P0-P6 mit Abnahmetests. P0 ist abgeschlossen; P1/P2 sind in Arbeit. Der additive Domänenvertrag und `compareProjectTimeslotEvidence` arbeiten bereits profilgebunden mit echten Scannerläufen, Dateiprovenienz, Processingkompatibilität, eindeutigem Scope und neuerer Reportzeit. Abwesenheit bleibt konservativ ungeklärt. Dieser sichere Pfad ist noch nicht an Persistenz/API oder Oberfläche angebunden und ersetzt daher die unten dokumentierte Legacyengine im Produkt noch nicht; die frühere Beschreibung bleibt zur Nachvollziehbarkeit erhalten.
## Zweck
Die Vergleichsengine ordnet Finding-Vorkommen aus zwei Scan-Läufen reproduzierbar ein. Sie arbeitet ausschließlich auf dem kanonischen Assessment-Modell und benötigt weder React noch Browser-, Tauri- oder Datenbankfunktionen.

View File

@ -0,0 +1,76 @@
# GPO-Backup-Vergleich mit Baselinevarianten
Stand: 2026-09-16 · Phase 7/13B · Evaluator `policy-baseline-evaluation@1.9.0`
## Prüfgegenstand
Geprüft wird die importierte GPO-Backup-Konfiguration gegen die ausdrücklich referenzierten Microsoft-Baselineanalysen. Das ist kein Nachweis der effektiv angewendeten Gerätekonfiguration, der Erreichbarkeit eines Dienstes oder einer tatsächlich ausnutzbaren Überprivilegierung. Baselinepakete bleiben getrennte Prüfungen; ein Ergebnis gilt nicht automatisch für andere Pakete, Betriebssysteme oder Rollen.
Ein PingCastle-Scan ist keine Voraussetzung. OS-Inventar und Domain Functional Level dürfen eine einzelne GPO weder automatisch einer Zielrolle zuordnen noch ihre Anwendung beweisen. Die vorhandene Vorlagenauswahl akzeptiert nur einen eindeutigen, expliziten Vergleichskontext; beobachtungsgebundene Inventarkontexte wählen keine Rolle aus. Unbekannte Vorlagen bleiben Kandidaten. Eine explizite Auswahl ohne passende Vorlage bleibt ungeklärt.
## Operativer Scope und Score
Die normale GPO-Analyse ist auf Fehlkonfigurationen innerhalb des tatsächlich konfigurierten, technisch vergleichbaren und ausdrücklich anwendbaren Kundenscopes begrenzt. Eine fehlende Microsoft-Empfehlung bleibt als `Not configured` transparent sichtbar, erzeugt aber weder ein operatives Finding noch eine Standard-Remediation oder einen Scoreabzug. Die vollständige Einführung einer Microsoft-Baseline ist ein eigener, ausdrücklich beauftragter Hardening-Scope.
Präzisierung vom selben Tag: `Not configured` bleibt ein score-neutrales Policy-Urteil. `Duplicates` und `Conflicts` bleiben eigenständige Diagnosekategorien der technischen Policy-Einstellung, sind aber keine alternativen Urteile, die Pass, Fail oder Review required verdecken. Die Mitgliedsvergleiche werden genau einmal je technischer Policy-Identität zusammengeführt: Weicht mindestens ein konfigurierter, eindeutig vergleichbarer In-Scope-Wert nachweislich ab, lautet das Gesamturteil der Policy-Einstellung `Fail`; Conflict-/Duplicate-Merkmale bleiben zusätzlich sichtbar. Bestehen alle anwendbaren Mitglieder, lautet das Urteil `Pass`. Sind Anwendbarkeit, Vorrang oder Vergleich ungeklärt, bleibt das Urteil `Review required`. Mehrere Quellzeilen, doppelte Vorkommen und Diagnosemarker vervielfachen den Scorebeitrag nicht.
Der GPO-Score ist deshalb ausschließlich als Konformität der konfigurierten, vergleichbaren In-Scope-Einstellungen zu bezeichnen. Er ist kein Nachweis vollständiger Microsoft-Baseline-Umsetzung oder -Abdeckung.
## Entscheidungsvertrag
Jede berücksichtigte Vorlage wird für dieselbe aufgelöste technische Einstellung einschließlich Computer-/Benutzerbereich einzeln verglichen. Der neutrale Vergleich verwendet exakte, typisierte Gleichheit. Bei Kontenlisten ist die Reihenfolge unerheblich; Originalwerte bleiben erhalten. Registrytypen und die Reihenfolge von REG_MULTI_SZ bleiben relevant. Abweichend bedeutet hier nicht automatisch schwächer: Eine gesonderte fachliche Regel wäre nötig, um eine strengere Einstellung als zulässig zu behandeln.
| Nachgewiesener Zustand | Ergebnis in Work |
| --- | --- |
| Konfiguriertes IST erfüllt jede berücksichtigte Variante | Pass |
| Konfiguriertes IST weicht von jeder berücksichtigten Variante ab | Fail / Filter Deviation |
| Einzelurteile widersprechen sich | Review required |
| Wertvergleich, Kandidatenabdeckung oder explizite Anwendbarkeit ist ungeklärt | Review required |
| Empfohlenes Setting besitzt im analysierten Backupsatz keinen beobachteten konfigurierten Wert | Not configured; score-neutral |
| Der analysierte Backupsatz ist nur ein ausgewählter Teilbestand | Not configured mit Scope-Diagnose; keine behauptete Abwesenheit in Domäne oder effektivem Gerätezustand |
| Für die technische Einstellung existiert überhaupt kein Baselinewert | Kein erfundener Pass-/Fail-Vergleich |
### Verbindliche Registry-Zustandsregeln
Registry.pol-Anweisungen sind Sollzustände und keine fehlenden Baselinewerte. Innerhalb des analysierten Backup-Sets gelten deshalb folgende Regeln; sie behaupten weiterhin keinen effektiven Registry-Zustand auf einem Gerät:
| Baseline-Sollzustand | Zustand im analysierten Kunden-Backup | Urteil |
| --- | --- | --- |
| Einzelnen Wert löschen | Zielwert nicht konfiguriert oder ausdrücklich gelöscht | Pass |
| Einzelnen Wert löschen | Zielwert wird gesetzt | Fail |
| Alle Werte eines Schlüssels löschen | Kein Wert für das Schlüsselziel konfiguriert oder ausdrückliche Delete-all-Anweisung | Pass |
| Alle Werte eines Schlüssels löschen | Ein Wert für das Schlüsselziel wird gesetzt | Fail |
| Schlüssel anlegen | Schlüsselanlage ist konfiguriert | Pass |
| Schlüssel anlegen | Schlüsselanlage fehlt | Fail |
| Typisierten leeren Text setzen | Wert existiert mit identischem Registrytyp und Inhalt `""` | Pass |
| Typisierten leeren Text setzen | Wert fehlt, besitzt einen anderen Typ oder ist nicht leer | Fail |
Ein leerer `REG_SZ`- oder `REG_EXPAND_SZ`-Inhalt ist damit ein expliziter Wert und niemals allein wegen seiner Länge „Baselinewert nicht verfügbar“. Delete-, Delete-all- und Create-key-Anweisungen erhalten einen typisierten Registry-Zustand (`absent`, `key-empty`, `key-present`) in der gespeicherten Vergleichsevidenz. Andere Sonderoperationen bleiben fail-closed `Review required`, bis ihre Semantik separat vereinbart und getestet ist.
Einträge außerhalb der Vereinigung aller erlaubten Kontenlisten weichen zwangsläufig von jeder dieser Varianten ab. Die Vereinigung ist dennoch **kein neuer Sollwert**. Auch innerhalb der Vereinigung ist bei exaktem Vergleich ein Fail möglich, wenn keine einzelne Variante vollständig erfüllt wird. Ein Treffer bei nur einer von mehreren widersprüchlichen Varianten ist kein Gesamt-Pass.
Eine fehlende oder unlesbare Variante neben vorhandenen Empfehlungen wird nicht herausgefiltert, um Einigkeit herzustellen. Strukturell unvollständige oder nicht erklärbar partielle Baselineanalysen blockieren das Gesamturteil. Partielle Diagnosen werden auf den betroffenen Bereitstellungsmechanismus begrenzt: Eine fehlerhafte Security-Template-Zeile blockiert keinen separat technisch aufgelösten Registry-Policy-Vergleich. Für eine Security-Template-Einstellung bleibt dieselbe Diagnose dagegen ein Blocker. Nicht unterstützte Sektionen besitzen keine vergleichbare Identität und bleiben als Abdeckungslücke am Lauf sichtbar, ohne einen unabhängigen aufgelösten Wert zu entwerten. Ausschließlich fehlende/mehrdeutige ADMX-Metadaten blockieren ebenfalls keine bereits technisch aufgelösten nativen Werte; die Diagnose bleibt erhalten.
Dasselbe gilt für explizit bekannte, wertunabhängige Metadatenlücken der Baseline: fehlende Domäne, Backupzeit oder Zeitzone. Diese Angaben belegen keine Gerätezielrolle und verändern den gelesenen Sollwert nicht. Unbekannte Diagnosen, fehlerhafte Metadatenstruktur und Identitätskonflikte werden nicht pauschal freigegeben.
Mehrere widersprüchliche Einträge derselben Einstellung innerhalb derselben Baseline-GPO sind keine unabhängigen Rollenvarianten. Ohne eindeutig reduzierte Empfehlung bleibt dieser Fall Review required, auch wenn das IST von sämtlichen Einzelwerten abweicht.
Unterschiedliche Baselineempfehlungen sind kein Kunden-GPO-Konflikt. Die bestehende Kundenquellenprüfung für Conflicts und Duplicates wird durch diese Änderung nicht neu definiert. Ihr Nachweis behauptet ebenfalls keine effektive Anwendung. Review required bleibt in Work unter All sichtbar; das verbindliche GPO-Statusmenü bleibt unverändert bei All, Deviation, Passed, Not configured, Conflicts und Duplicates. Eine Risikoakzeptanz verändert dieses technische Urteil nicht.
Die spätere Nutzerpräzisierung vom 2026-09-16 ersetzt ausschließlich die frühere Interpretation score-neutraler Conflict-/Duplicate-Container: Der Container ist seit Evaluator 1.7.0 ein Diagnosemerkmal, das Policy-Gesamturteil wird separat aufgerollt. Work speichert das Urteil im Status und Conflict/Duplicate zusätzlich in den eigenen Feldern; die Statusfilter lesen diese Achsen getrennt. Dadurch kann dieselbe Zeile sowohl `Deviation` als auch `Conflicts` beziehungsweise `Duplicates` erfüllen, ohne einen zweiten Scorebeitrag zu erzeugen. Historische Evaluationen werden nicht rückwirkend umgedeutet.
## Nachweis und Historie
Praezisierung 2026-09-18 (Work-Zeilen): Mehrere Baselinepakete fuer dieselbe technische Einstellung werden in Work zu genau einer Zeile je Projekt, Snapshotaggregation und Computer-/Benutzerbereich zusammengefasst. Diese Zeile verwendet dieselbe Urteilprioritaet wie die bestehenden Policy-Zaehler (Fail, Review required, Pass, Not configured). Die innerhalb eines Pakets gespeicherte Variantenentscheidung bleibt unveraendert; ein dort gemischter Vergleich bleibt Review required. Der Baselinefilter waehlt Mitgliedschaften dieser gemeinsamen Zeile und berechnet ihr Gesamturteil nicht aus der Filterauswahl neu. Einzelne Paketvergleiche, Settingreferenzen und unterschiedliche Sollwerte bleiben im Reader und in Result DB nachvollziehbar; es entsteht kein synthetischer gemeinsamer Sollwert. Quellen werden anhand ihrer exakten Beobachtungsidentitaet dedupliziert, rote Markierungen muessen gegen alle erhaltenen aktuellen Varianten belegt sein. Historische oder nicht verfuegbare Bewertungen erlauben keine solche Markierung.
Bestehende lokale Notizen zu frueheren Baselinezeilen bleiben im Reader unter `Previous baseline-specific reviews` lesbar. Uebereinstimmende Notizen werden fuer die gemeinsame Work-Zeile uebernommen; unterschiedliche Notizen werden nicht willkuerlich vereinigt. Risikoakzeptanzen werden niemals automatisch auf die gemeinsame Zeile umgehaengt. Die Projektion benoetigt weder Reimport noch Neuauswertung; gespeicherte Vergleiche und Datenbankschema bleiben unveraendert.
Jedes neue Ergebnis speichert `baselineVariantAssessment` Version 1 mit Prüfgrenze `backup-configuration`, unveränderten Kunden-/Baselinewerten, Baseline-Settingreferenzen, typisierten Einzelvergleichen, Blockierungsdiagnosen und Gesamtentscheidung. Ausgewählte Settingreferenzen und sämtliche Auswahlkandidaten bleiben getrennt; die vorhandenen Evidenzreferenzen führen zu Analyse, Datei-/Hash-/Zeilenherkunft und Kundenbeobachtungen. Die Schreibvalidierung prüft Referenzabdeckung und Konsistenz von Einzelurteilen, Gesamturteil und Klassifikation.
Ein einzelner aggregierter Baselinewert wird nur bei tatsächlich gemeinsamem Quellwert ausgegeben. Bei unterschiedlichen Werten zeigen Work, Result DB und der JSONL-Export die Einzelvarianten. Empfehlungen kombinieren niemals die Alternativen; vor einer konkreten Änderung muss die passende Vorlage feststehen.
Evaluator 1.5.0 ersetzte die pauschale Sperre aus 1.4.0 bei unterschiedlichen Baselinewerten. Evaluator 1.6.0 begrenzt globale Parserdiagnosen auf den tatsächlich betroffenen Bereitstellungsmechanismus. Evaluator 1.7.0 vergleicht die erhaltenen Mitgliedswerte eines Kundenkonflikts einzeln und rollt einen nachgewiesenen Fail auf die technische Policy-Einstellung hoch. Evaluator 1.8.0 hält gewöhnliche fehlende Empfehlungen auch in einem ausgewählten analysierten Backupsatz als `Not configured` filterbar und markiert die begrenzte Aussage mit `policy-evaluation-customer-absence-scope-limited`; eine Domänen- oder Geräteabwesenheit wird nicht behauptet. Evaluator 1.9.0 behandelt die oben vereinbarten Delete-, Delete-all-, Create-key- und typisierten Leerwert-Sollzustände als vergleichbare Baselineevidenz. Alte Läufe werden nicht umgeschrieben. 1.3.3 bis 1.8.0 bleiben historisch lesbar; die aktuelle Version hat Vorrang. Data Hub fordert über den bestehenden Versionsvergleich eine explizite Re-evaluation der erhaltenen Eingaben an. Kein Scanner-Reimport, erneuter Upload oder SQL-Schemawechsel allein wegen dieses Bewertungswechsels; fehlende/unlesbare Ursprungsevidenz bleibt gesondert zu klären.
## Verifikation und Grenzen
Synthetische Regressionen prüfen zusätzliche und fehlende Konten, Treffer nur einer Variante, Übereinstimmung aller Varianten, vertauschte Kontenreihenfolge, Typunterschiede, unbekannte/fehlende/unlesbare Varianten, explizite und inventarbasierte Kontexte, fehlende Einstellungen bei vollständiger und partieller Erfassung, die vier Registry-Zustandsregeln einschließlich ihrer Work-Urteile, unveränderte Eingaben, serialisierte Evidenz, Schreibvalidierung, historische Versionsauswahl sowie Work-Filter, Guidance und gerenderte Result-DB-Details. Historische Kundendaten werden nicht automatisch neu bewertet. Die Standardprüfung ersetzt keine freigegebene Integration gegen den kanonischen PostgreSQL-Bestand und keinen RSoP-/GP-Results-Nachweis.

View File

@ -2,6 +2,210 @@
Status: lebendes Projektdokument
Maester-Unicode-Null-Import 2026-09-20 (Phase 13A/13C, Nutzerfehlerbericht, umgesetzt und verifiziert): Ein nativer Maester-Fehlertext enthielt `U+0000`. Vorschau und Mapping waren erfolgreich, der atomare PostgreSQL-Abschluss scheiterte jedoch, weil `jsonb` dieses Zeichen nicht repraesentieren kann; die bisherige Auto-Import-Fehlerbehandlung ersetzte danach die erfolgreiche Vorschau irrefuehrend durch `0 of 0 files selected`. Die gemeinsame kanonische Importgrenze stellt jedes solche Zeichen nun explizit als lesbares `\\u0000` dar, behaelt den gesamten umgebenden Quellwert, Hash und Locator und erzeugt die Diagnose `unicode-null-escaped`. Andere Steuerzeichen, Parsing, Profilwahl, Normalisierung und Bewertung bleiben unveraendert. Nach einem Abschlussfehler bleibt die vorbereitete Dateizeile samt Wiederholungsmoeglichkeit sichtbar. Ein synthetischer Maester-XLSX-Test und der vollstaendige HTTP-Pfad Vorschau -> bestaetigte Zuordnung -> Import in vorhandenen Timeslot sichern genau die Regression; 26 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only- und Designpruefung bestehen. Der optionale PostgreSQL-Testlauf wurde gemaess kanonischer-Datastore-Regel nicht gegen die bestehende Kundendatenbank ausgefuehrt. Keine Migration, Schema- oder Bestandsdatenaenderung; Phase 13A bleibt abgeschlossen, Phase 13C in Arbeit.
Client-Info-Security-Domain-Mengenbalken 2026-09-20 (Phase 7, ausdrueckliche Nutzerentscheidung und nachfolgende Ausrichtungs-/Farbkorrekturen, umgesetzt und verifiziert): Jede angezeigte Security-Domain-Zeile traegt einen dezenten Mengenbalken in einer eigenen gemeinsamen Rasterspalte zwischen dem laengsten gerenderten Domainnamen und der breitesten gerenderten Mengenspalte. Dadurch liegen beidseitig 12 px Abstand; kein Balken kann in Schrift oder hinter eine Zahl wachsen. Alle Balken enden an derselben Kante und wachsen von dort nach links. Seine Breite entspricht dem sichtbaren Abweichungs- beziehungsweise Review-only-Wert der Zeile geteilt durch die Summe aller angezeigten Domainwerte; die exakte rechtsbuendige Zahl bleibt die autoritative sichtbare Angabe. Die spaetere Nutzerkorrektur ersetzt die kurzzeitig gewaehlte kategoriale `--viz-*`-Folge: Jeder Balken liest nun exakt denselben aktuellen semantischen Farbwert wie der Overall-Score-Donut und wechselt gemeinsam mit dessen Critical-, Medium-, Passed- oder Unknown-Tone. Die Balken sind rein dekorativ und fuer assistive Technik verborgen. Vier fokussierte Suites mit 23 Tests, die Designsprachpruefung sowie Client-/Server-Produktionsbuild bestehen. Die Live-Pruefung am freigegebenen Dummy bestaetigt fuer alle neun Zeilen getrennte Text-/Balken-/Zahlenspalten, beide 12-px-Abstaende, identische Donut-/Balkenfarbe `rgb(232, 33, 79)` und keinen horizontalen Ueberlauf in Light und Dark; Light wurde wiederhergestellt. Keine Daten-, Klassifikations-, Score-, Drilldown-, API- oder Persistenzaenderung.
Client-Info-Lesetypografie 2026-09-20 (Phase 7, ausdrueckliche Nutzerentscheidung, umgesetzt und verifiziert): Informationstragende Beschriftungen und strukturierte Tabellen-/Kachelinhalte verwenden mindestens die 14-px-Datenrolle; eigentliche Lesewerte und Fliesstext verwenden die 16-px-Leserolle. Kompakte Controls, technische Metadaten und Badges bleiben getrennte Ausnahmen. Environment Detail setzt den Vertrag fuer Fakten, Source Overview und Operating systems um; `Domain` rendert mit 14 px/700 und `sevenkingdoms.local` mit 16 px/400. Drei fokussierte Suites mit 23 Tests, Designsprachpruefung und Client-/Server-Build bestehen. Die Live-Pruefung bestaetigt dieselben Groessen in Light und Dark ohne horizontalen Kachelueberlauf; Light wurde wiederhergestellt.
Client-Info-PingCastle-OS-Zustaende 2026-09-20 (Phase 7, ausdrueckliche Nutzererweiterung, umgesetzt und verifiziert): Die Operating-systems-Tabelle zeigt neben `Devices` nun die von PingCastle je Betriebssystemzeile gelieferten Werte `Active`, `Inactive`, `Enabled` und `Disabled`. Die Projektion verbindet diese Werte ausschliesslich ueber identischen Scanlauf, Importdatei, Abschnitt, Quelltabelle und Quellzeile; sie berechnet keinen Zustand aus einem anderen und addiert weiterhin keine ueberlappenden Inventare. Fehlende Werte aus aelteren oder anderen Quellen bleiben `—`. Die read-only-Pruefung am freigegebenen Dummy bestaetigt fuer `Windows Server 2019 1809` die Quellwerte `1 / 1 / 0 / 1 / 0`; die zweite unquantifizierte Quelle bleibt in allen fuenf Spalten unbekannt. Drei fokussierte Suites mit 28 Tests, Client-/Server-Build, Designsprachen- und Local-only-Sicherheitspruefung bestehen. Light und Dark wurden live geprueft; alle Spalten passen bei 568 px Kachelbreite ohne horizontalen Ueberlauf, Light wurde wiederhergestellt.
Data-Hub-/Client-Info-Deviation-Abgleich 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Data Hub berechnet seine Ergebnisdonuts nicht mehr nur aus scannergebundenen Run-IDs, sondern aus demselben ausgewaehlten gemischten Timeslot-Projektblatt wie Client Info. Assessmentgebundene GPO-/Policy-Ergebnisse bleiben dadurch enthalten; der freigegebene Dummy zeigt in beiden Ansichten `477` Deviations bei `1.833` Gesamtdatensaetzen beziehungsweise `26 %`. Beide Ansichten verwenden dieselbe zentrale autoritative Ergebniszustandsklassifikation. Der Client-Info-Donut uebernimmt zudem exakt die Data-Hub-Referenzvariante samt Kachelflaeche, Groesse, Padding, Titeltypografie, Ringgeometrie, Farben und Hover. Beim Mandantenwechsel wird die vorige Kennzahl sofort geleert, damit waehrend der asynchronen Projektion keine Zahl eines anderen Mandanten sichtbar bleibt. Vier fokussierte Suites mit 32 Tests, Client-/Server-Build, Designsprachen- und Local-only-Sicherheitspruefung bestehen. Die laufende Dummy-Ansicht bestaetigt `477 / 1.833 / 26 %` in beiden Bereichen; berechnete Light- und Dark-Styles sind identisch und Light wurde wiederhergestellt.
Data-Hub-Mandantenauswahlkontur 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die persistente gelbe Kontur der ausgewaehlten Mandantenkachel ist gezielt von 2 px auf 3 px verstaerkt. Die Breite wird aus den bestehenden Linienrollen `--bw-emphasis` und `--bw-hairline` zusammengesetzt; Farbe, Innenversatz, Kachelgroesse und Layout bleiben stabil. Diese Ausnahme gilt nur fuer die ausgewaehlte Data-Hub-Mandantenkachel. Der getrennte blaue 2-px-Tastaturfokus, Hover, nicht ausgewaehlte Kacheln und alle anderen Auswahlkonturen bleiben unveraendert. Zwei fokussierte Suites mit 10 Tests, Client-/Server-Build, Designsprachen- und Local-only-Sicherheitspruefung bestehen. Die laufende Data-Hub-Ansicht bestaetigt die staerkere gelbe Kontur und `aria-pressed=true` in Light und Dark; Light wurde anschliessend wiederhergestellt.
Light-Mode-Kachelflaeche-Nachschaerfung 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die gemeinsame Light-Mode-Kachelflaeche wird von dem weiterhin zu hellen `#FAFAFC` auf das dezentere Neutralgrau `#F5F5F7` abgesenkt. Die Aenderung bleibt auf `--tile-surface` begrenzt und gilt damit konsistent fuer Data Hub, Work, Client Info, DB Structure und Pathfinder. Die Kacheln bleiben heller als der App-Hintergrund `#F1F1F4`; Hover-, Auswahl-, Fokus-, Status- und Dark-Mode-Rollen bleiben unveraendert. Vier fokussierte Suites mit 24 Tests, Client-/Server-Build, Designsprachen- und Local-only-Sicherheitspruefung bestehen. Die laufende Client-Info-Ansicht bestaetigt `rgb(245, 245, 247)` im Light Mode und den unveraenderten Dark-Mode-Wert `rgb(50, 50, 56)`; Light wurde anschliessend wiederhergestellt.
Client-Info-Deviations-Donut 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die Gesamtzahl `Deviations` in der Kachel `Data` wird nun mit der bestehenden gemeinsamen `DonutMetricCard` als vollstaendiger 360-Grad-Ring dargestellt. Die absolute Anzahl steht fett und zentriert, darunter der Anteil an allen Datensaetzen des aktuellen Client-Info-Projekts; beim freigegebenen Dummy sind das `477` und `26 %` aus `477 / 1.833`. Der Donut liegt transparent in der vorhandenen Kachel, erzeugt keine verschachtelte Kachelflaeche und bleibt vollstaendig per Maus und Tastatur als Drilldown nach Work mit `Deviation` bedienbar. Vier fokussierte Suites mit 27 Tests, Client-Build, Designsprachen- und Local-only-Sicherheitspruefung bestehen. Die laufende Browseransicht wurde in Light und Dark geprueft; Ring, Innenabstand, Ausrichtung, Beschriftung und zugaenglicher Name `Deviations: 477, 26%` sind korrekt, der urspruengliche Light Mode wurde wiederhergestellt.
Client-Info-Environment-Detail-Icons-and-Spacing 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Alle acht Kachelueberschriften in Environment Detail, einschliesslich `Source Overview` und `Operating systems`, tragen nun genau ein bestehendes dezentes ASSESSOR-Liniensymbol in der abgesetzten Textfarbe. Die Faktengruppen uebernehmen den Listenabstand und das Zeilenpolster von Source Overview (`12px 0 16px` beziehungsweise `12px 8px`) und wirken dadurch luftiger und einheitlich. Vierer-Raster, feste Kachelhoehen und internes Scrollen bleiben erhalten. Verifiziert wurden 28 fokussierte Vitest-Faelle, Client- und Server-Build, Designsprachen- und Local-only-Sicherheitspruefung sowie die laufende Browseransicht in Dark und Light mit acht Ueberschriften und acht Symbolen.
Client-Info-Environment-Facts-in-Data 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die separate obere Environment-Facts-Kachelreihe wurde entfernt. AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers und GPOs stehen nun samt ihrer bestehenden Symbole innerhalb der `Data`-Kachel unter den zusaetzlichen Ergebniszustaenden. Dort bilden sie ein flaches 3-mal-2-Raster mit transparenten Zellen und dezenten Trennlinien statt verschachtelter Kacheln. Werte, unbekannt-Semantik und Datenherkunft bleiben unveraendert. Ein synthetischer Interaktionstest sichert alle sechs Fakten, Symbole und die Abwesenheit der frueheren oberen Reihe dauerhaft ab. Verifiziert wurden 29 fokussierte Vitest-Faelle, Client- und Server-Build, Designsprachen- und Local-only-Sicherheitspruefung sowie die laufende Browseransicht mit exakt sechs Fakten und Symbolen, drei gleich breiten Spalten, entfernter alter Faktenleiste und ausgerichteten Unterkanten von `Data` und `Domain Information`.
Client-Info-Environment-Detail-Kachelraster 2026-09-20 (Phase 7, ausdrueckliche Nutzererweiterung, umgesetzt und verifiziert): Der gesamte aufgeklappte Environment-Detail-Inhalt ist als achtteiliges Raster organisiert: Source Overview, sechs fachliche Faktengruppen und Operating systems. Das Raster behaelt immer vier gleich breite Kacheln pro Reihe. Ein `ResizeObserver` leitet die einheitliche Kachelhoehe aus 75 Prozent der tatsaechlich gerenderten Overall-Score-Kachel ab; ueberlange Inhalte scrollen innerhalb ihrer Kachel. Auf schmalen Ansichten bleiben Spaltenzahl und Kachelbreite durch horizontalen Raster-Scroll stabil. Die Source-Overview-Kachel behaelt innen die vereinbarte schlichte Quellenliste und alle Drilldowns. Die Live-Pruefung bestaetigt acht Kacheln, vier gleich breite Spalten zu rund 578 px und 504,4 px Kachelhoehe bei 672,6 px Overall-Score-Hoehe; Source Overview und Source metrics scrollen intern. 29 fokussierte Dashboard-/Environment-/Interaktionstests, Designsprachpruefung, Client-/Server-Produktionsbuild und Local-only-Sicherheitspruefung bestehen.
Client-Info-dominante-Abweichungszahl 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die klickbare Gesamtzahl `Deviations` in der Kachel `Data` verwendet nun die vorhandene 56-px-H1-Rolle mit kompakter Zeilenhoehe und groesserem vertikalem Abstand. Die Beschriftung bleibt direkt darunter und ist als semibold Feldlabel klar lesbar; die vier zusaetzlichen Ergebniszustaende bleiben bewusst kleiner. Farbe, Datenwert, Kachelgeometrie und der bestehende Work-Drilldown bleiben unveraendert. Die Live-Pruefung bestaetigt `477` mit 56 px/600 sowie `Deviations` mit 12 px/600 ohne Hintergrund oder Schatten. 18 fokussierte Dashboard-/Interaktionstests, Designsprachpruefung, Client-/Server-Produktionsbuild und Local-only-Sicherheitspruefung bestehen.
Client-Info-Quellenliste-im-Environment-Detail 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Der separate Bereich `Source Overview` wurde aus der Hauptansicht entfernt. Dieselben Quelleninformationen stehen nun im aufgeklappten `Environment Detail` als kompakte Aufzaehlung mit Trennlinien und transparenten Zeilen statt Kacheln. ASSESSOR-Score, Bewertungsmethode, native Kontextwerte, Provisional-Hinweis, Scoring-Methodology-Dialog und die bestehenden Work-Drilldowns bleiben erhalten. Die sechs fachlichen Environment-Gruppen und der initial eingeklappte Zustand bleiben unveraendert. Die Live-Pruefung bestaetigt fuenf Quellenzeilen innerhalb von `Environment Detail`, keinen eigenstaendigen Source-Overview-Bereich sowie transparente, schattenfreie Darstellung in Light und Dark. 29 fokussierte Dashboard-, Environment- und Interaktionstests, Designpruefung, Client-/Server-Build und Local-only-Sicherheitspruefung bestehen.
Client-Info-Data-Kachel 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die ehemalige Kachel `Priority Findings` heisst nun `Data` und tauscht mit `Domain Information` die Position, sodass `Data` links und die Domaininformationen rechts stehen. Die doppelten Kennzahlen `Critical` und `High` sind entfernt; Gesamtzahl `Deviations` und die getrennte Zeile der zusaetzlichen Ergebniszustaende bleiben erhalten. Der Kopf verwendet eine einzige volle Rasterspalte ohne Versatz. Die Abweichungszahl ist eine tastaturbedienbare Schaltflaeche und oeffnet Work mit dem Standardfilter `Deviation`. Der nicht mehr benoetigte Severity-Zaehler wurde entfernt; ein synthetischer Interaktionstest sichert Beschriftung, Reihenfolge, reduzierte Kennzahlen und den Drilldown ab. Die Live-Pruefung bestaetigt `Data`, `477 Deviations`, die Reihenfolge Data/Domain Information/Assessment Gaps sowie die gemeinsame 4-px-Buttongeometrie; Light und Dark bleiben lesbar. 18 fokussierte Dashboardtests, Designpruefung, Client-/Server-Build und Local-only-Sicherheitspruefung bestehen.
CIS-CAT-Result-Belegfarbe 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Ausgewertete Quellzeilen in verknuepften CIS-CAT-Results werden nicht mehr pauschal rot dargestellt. Das tatsaechliche Finding-Outcome wird in das Results-Fenster weitergereicht: `Pass` verwendet die eigene gruene Match-Rolle, `Fail` die rote Violation-Rolle; fuer unbekannte oder neutrale Outcomes wird keine Aussage erfunden. Erwartungskriterium und bestaetigter Ist-Wert bleiben semantisch getrennte Rollen. Ein synthetischer Interaktionstest sichert den Pass-Fall mit `Type = reg_dword` und `Value = 0` sowie den unveraenderten Fail-Fall ab. Die 22 fokussierten Results-/Layout-Tests, Designpruefung, Client-/Server-Build und Local-only-Sicherheitspruefung bestehen; die Theme-Pruefung bestaetigt die Match-Rollen im Light Mode (`#CBEFD9` / `#10261A`) und Dark Mode (`#176B46` / `#FFFFFF`).
Light-Mode-Kachelflaeche 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die gemeinsame Kachelflaeche verwendet im hellen Theme statt Reinweiss nun das sehr dezente Neutralgrau `#FAFAFC`. Die Aenderung erfolgt ausschliesslich ueber `--tile-surface` und wirkt damit konsistent in Data Hub, Work, Client Info, DB Structure und Pathfinder; App-Hintergrund, Eingabeflaechen, Hover, Auswahl, Statusfarben und Dark Mode bleiben unveraendert. Die Live-Pruefung bestaetigt `rgb(250, 250, 252)` in Data Hub, Work und Client Info sowie den unveraenderten Dark-Mode-Wert `rgb(50, 50, 56)`; fokussierte Tile-/Layout-Tests, Design-Token-Pruefung, Client-/Server-Build und Local-only-Sicherheitspruefung bestehen.
Client-Info-Environment-Feldueberschriften 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die Feldueberschriften in den aufgeklappten Environment-Detail-Gruppen, einschliesslich `Directory`, verwenden nun dieselbe Bold-Stufe 700 wie die kompakte Kachel `Domain Information`. Werte bleiben regulaer; Daten, Reihenfolge, Abstaende und Kachelgeometrie sind unveraendert.
Accepted-Risk-Draft-Markierung 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die Kopfaktion `Accepted Risk` verwendet ausschliesslich im vorhandenen Zustand `Draft` eine blaue Flaeche mit weisser Schrift. Dafuer besitzt der Entscheidungsentwurf eine eigene semantische Farbrolle, getrennt von Auswahl, Severity und Tastaturfokus; der sichtbare Text `Draft` bleibt als zweites Zustandssignal erhalten. Leerer Zustand, angenommene, faellige, abgelaufene und widerrufene Entscheidungen bleiben unveraendert.
Client-Info-Kachelflucht 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): In der breiten 1:1-Hauptzeile fuellt die rechte Aktionsspalte wieder exakt die Hoehe der Posture-Kachel. `Domain Information` und `Priority Findings` bleiben als gleich hohe obere Zeile erhalten; `Assessment Gaps` nimmt den verbleibenden Platz ein, sodass beide Hauptkacheln dieselbe Unterkante besitzen. Im gestapelten schmalen Layout bleiben alle Bereiche auf ihrer natuerlichen Inhaltshoehe. Inhalte, Daten und Aktionen sind unveraendert.
Accepted-Risk-Leerzustand 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Solange fuer ein Finding keine Risikoakzeptanz erfasst ist, zeigt die bestehende Kopfaktion `Accepted Risk ?`. Sobald ein Eintrag vorhanden ist, ersetzt weiterhin dessen bestehender Entscheidungsstatus das Fragezeichen. Dialog, Risikoentscheidung, Persistenz und Findingdaten bleiben unveraendert.
Client-Info-Domain-Information-Typografie 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): In der Kachel `Domain Information` sind der Kacheltitel und alle Feldueberschriften fett gesetzt. Die Werte behalten ihre regulaere Datenrolle, damit Ueberschrift und Inhalt eindeutig unterscheidbar bleiben; Daten, Reihenfolge und Kachelgeometrie sind unveraendert.
Work-Sidebar-Security-Domains 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die bisherige Severity-Donut-Leiste in Work entfaellt vollstaendig. An ihrer Stelle stehen die acht gemeinsamen Client-Info-Security-Domains in Katalogreihenfolge untereinander; `Unclassified` erscheint nur, wenn es im geladenen Scope vorkommt. Jeder Eintrag zeigt die gegen alle anderen aktiven Work-Filter selbst-ausschliessend nachgezaehlten Treffer und setzt exakt dieselbe sichtbare `Security Domain`-Facette wie die Toolbar. Ein erneuter Klick auf die aktive Domain hebt die Auswahl auf, ein Klick auf eine andere Domain ersetzt sie; Nulltreffer bleiben gemaess Work-Vertrag anklickbar. Auswahl verwendet ausschliesslich die gemeinsame gelbe Markierung, Tastaturfokus bleibt blau. Quelldaten, Domainzuordnung und andere Filter bleiben unveraendert.
Abnahme der Work-Sidebar-Security-Domains: 26 fokussierte Domain-, Interaktions-, Navigations-, Donut- und Designvertragstests sowie Client-/Server-Produktionsbuild und Designsprachpruefung bestanden. Die Live-Pruefung am freigegebenen Dummy bestaetigt in Light und Dark die vollstaendige Entfernung der Severity-Donuts, die gemeinsame Domainreihenfolge mit aktuellen Trefferzahlen, die gelbe aktive Auswahl und die bidirektionale Synchronisierung mit der sichtbaren Toolbar-Facette. `Identity & Account Hygiene` reduziert den Scope reproduzierbar von 477 auf 221 Treffer; erneuter Klick stellt `Security Domain: All` und 477 Treffer wieder her. Light und der ungefilterte Domainzustand wurden wiederhergestellt. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Info-Posture-Reduktion 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Der wiederholte Hinweis `Pass rates · … total deviations` entfaellt aus dem Security-Domains-Kopf. Die Domainzeilen behalten ihre klickbaren Scoreknoten und den numerischen Deviation-/Review-Zaehler, zeigen darunter jedoch keine wiederholte Bezeichnung mehr; die Bedeutung bleibt als Tooltip erhalten. Die komplette dreizeilige Finding-Vorschau und die Aktion `View findings` in Priority Findings entfallen. Die autoritativen Summen und klickbaren zusaetzlichen Ergebniszustaende bleiben erhalten; Findingdaten und die verbleibenden fachlichen Drilldowns bleiben unveraendert.
Abnahme der Client-Info-Posture-Reduktion: Ein synthetischer Dashboard-Vertrag prueft dauerhaft die Abwesenheit des Gesamthinweises, aller sichtbaren Domain-Zaehler-Unterzeilen, der Finding-Vorschauliste und der Aktion `View findings`. 20 fokussierte Dashboard-/Posture-/Domainfiltertests, Client-/Server-Produktionsbuild sowie Designsprach- und Local-only-Pruefung bestanden. Die Live-Pruefung am freigegebenen Dummy bestaetigt in Light und Dark die reduzierte Darstellung, unveraenderte Kennzahlen und die gemeinsame Unterkante von Domain Information und Priority Findings; Light wurde als Ausgangsthema wiederhergestellt. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Globale Button-Abrundung 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Alle rechteckigen klickbaren Bedienelemente verwenden jetzt den gemeinsamen leichten `--btn-radius` von 4 px. Das umfasst Work-Aktionen wie `Snapshot` und `Reset`, View-Tabs, Sidebar-, Theme-, Icon-, Disclosure-, Segment-, Dialog- und Pathfinder-Schaltflaechen; eingebettete Randaktionen runden ihre sichtbaren Aussenkanten mit demselben Token. Echte Kreis-/Donut-Interaktionen und quadratische Inhaltskacheln behalten ihre fachliche Geometrie. Farben, Aktionen, Navigation und Datenlogik bleiben unveraendert.
Abnahme der globalen Button-Abrundung: Der Designsprachtest sichert den gemeinsamen Basisvertrag, die zuvor abweichenden spaet geladenen View-Regeln sowie die beiden semantischen Ausnahmen dauerhaft ab. Client-/Server-Produktionsbuild, Designsprach- und Local-only-Pruefung bestanden. Die visuelle Live-Pruefung am freigegebenen Dummy bestaetigt Snapshot, Reset, View-Tabs, Export-, Icon- und Sidebar-Aktionen in Light und Dark; Light wurde als Ausgangsthema wiederhergestellt. Gesamtsuite: 1.419 bestanden, 13 optionale PostgreSQL-Tests uebersprungen; zwei fachfremde bestehende Quelltextannahmen zu einem erlaubten `code`-Element und der inzwischen erweiterten `openProject`-Signatur bleiben fehlgeschlagen. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Info-Domainzeilen-und-Score-Einstieg 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die Security-Domain-Zeilen zeigen den Domaenennamen jetzt in der 20-px-Detailtitelrolle und entfernen die bisherige Meta-Unterzeile mit Passed/Not configured/Duplicates/Conflicts/Review. Scoreknoten sowie rechter Deviation-/Review-Zaehler bleiben erhalten. Der Overall-Score-Ring ist nun eine per Maus und Tastatur bedienbare Aktion: Er oeffnet Work mit dem sauberen Remediation-Standard `Deviation`, entfernt vorherige Source-, Domain-, Severity-, Such-, Workspace- und Record-ID-Filter und zeigt die regulaere Work-Uebersicht. Score-, Domaenen- und Findingdaten bleiben unveraendert.
Abnahme der Domainzeilen und des Score-Einstiegs: 26 fokussierte Dashboard-/Posture-/Domainfilter-/Navigationstests, Designsprach- und Local-only-Pruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung am freigegebenen Dummy bestaetigt die vergroesserten Domaenennamen ohne Meta-Unterzeilen in Light und Dark; der Score-Klick oeffnet Work mit `Deviation · 477` und allen Source-, Domain- und Severity-Filtern auf `All`. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Ladehinweis 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Der generische Text `Loading saved client …` ist durch `Loading Client: [Clientname]` ersetzt. Der Name wird beim Oeffnen direkt aus der ausgewaehlten Data-Hub-Projektidentitaet uebergeben und fuer nachfolgende Timeslot-/Refresh-Ladevorgaenge desselben Clients stabil wiederverwendet; der deaktivierte Sheet-Selector zeigt denselben dynamischen Status. Keine Lade-, Scope-, Daten- oder Persistenzlogik wurde geaendert.
Abnahme des Client-Ladehinweises: Fuenf fokussierte Ladezustands-/Data-Hub-/Timeslot-Tests und Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung beim Oeffnen des freigegebenen Dummy bestaetigt sichtbar `Loading Client: Dummy`; der bestehende Bundlegroessenhinweis bleibt unveraendert. Phase 7 bleibt in Arbeit.
Donut-Innenhierarchie 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Alle Anzahl-/Prozent-Donuts zeigen jetzt die absolute Anzahl fett in der ersten Zeile und den Prozentwert mit regulaerem Gewicht darunter. Ein gemeinsamer tokenbasierter Sicherheitsabstand haelt den vollstaendigen Readout bei grossen Data-Hub-, kompakten Sidebar-, Dashboard-Status-, Risk-Severity- und Client-Info-Verteilungsringen vom inneren Ringrand fern. Der Overall Score behaelt als Score-Ring seine fachlich abweichende `/100`-Semantik. Werte, Prozente, Filteraktionen und Scorelogik bleiben unveraendert.
Abnahme der Donut-Innenhierarchie: 16 fokussierte Donut-/Data-Hub-/Sidebar-/Disclosure-/Tile-Vertragstests, Designsprach- und Local-only-Pruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung mit dem freigegebenen Dummy bestaetigt Anzahl oben, Prozent darunter sowie sichtbaren Innenabstand bei realen Werten in Light und Dark. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Info-vollstaendiger-Directory-Kontext 2026-09-20 (Phase 7, ausdrueckliche Nutzererweiterung, umgesetzt): `Domain Information` zeigt jetzt alle neun Fakten der Environment-Detail-Gruppe `Directory`: Domain, NetBIOS Name, Forest, Domains, Domain Functional Level, Forest Functional Level, Schema Version, Domain Created und Entra ID. Die zuvor gewuenschte Domain-Controller-Zahl bleibt als zusaetzlicher zehnter Fakt erhalten. Werte und Unbekanntbehandlung stammen unveraendert aus dem normalisierten Environment Snapshot; die 50/50-Geometrie und gleiche Kachelhoehe zu Priority Findings bleiben bestehen.
Abnahme des vollstaendigen Directory-Kontexts: 22 fokussierte Dashboard-/Environment-/Quellenauswahltests, Designsprach- und Local-only-Pruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung am freigegebenen Dummy bestaetigt alle zehn Fakten ohne Abschneiden oder Ueberhang und die gemeinsame Unterkante mit Priority Findings in Light und Dark. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Data-Hub-Kachelflucht 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Ergebnis-Donuts und Mandanten-/Add-Kacheln verwenden auf breiten Flaechen jetzt dasselbe Sechs-Spalten-Raster. Spaltenstart, Spaltenbreite und Gap sind identisch; dadurch entfaellt der zuvor mit jeder Mandantenkachel anwachsende horizontale Versatz, waehrend alle sechs Donuts in einer Zeile bleiben. Unterhalb des Wide-Breakpoints gilt fuer beide Reihen wieder derselbe kompakte responsive Tile-Vertrag. Inhalte, Auswahl, Status und Navigation bleiben unveraendert.
Abnahme der Data-Hub-Kachelflucht: 14 fokussierte Donut-/Data-Hub-/Tile-Vertragstests, Designsprach- und Local-only-Pruefung sowie Client- und Server-Produktionsbuild bestanden. Die Live-Pruefung in Light und Dark bestaetigt durchgehend fluchtende Kanten fuer alle sechs Donuts sowie Dummy, Maester Test, LSW und die Add-Kachel. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Info-Domainkontext 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die obere Aktionsrail ist jetzt eine exakte 50/50-Zeile mit `Domain Information` links und `Priority Findings` rechts. Beide Kacheln besitzen dieselbe Hoehe und Unterkante; `Assessment Gaps` nutzt darunter wieder die komplette Railbreite. Domain, Domain Functional Level, Domain Controllers, Forest, Forest Functional Level und Schema Version stammen aus denselben normalisierten Environment-Snapshot-Beobachtungen wie Environment Detail. Fehlende Werte bleiben `—`; Score-, Finding-, Filter-, Drilldown- und Persistenzlogik sind unveraendert.
Abnahme des Client-Info-Domainkontexts: 17 fokussierte Dashboard-/Quellenauswahltests, Designsprach- und Local-only-Pruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung mit dem ausdruecklich freigegebenen Dummy bestaetigt die gleich breite und gleich hohe obere Kachelzeile, die vollbreiten Assessment Gaps, quellengestuetzte Werte fuer DFL, DC, Forest, FFL und Schema sowie lesbare Umbrueche in Light und Dark. Der bestehende Bundlegroessenhinweis bleibt unveraendert; Phase 7 bleibt in Arbeit.
Client-Info-Posture-/Risikoregister-Korrektur 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die breite Client-Info-Hauptzeile teilt die verfuegbare Breite jetzt exakt zur Haelfte zwischen Posture und Aktionsrail; diese spaetere Korrektur ersetzt die kurzzeitig verwendete 2:1-Aufteilung. Kacheln derselben Zeile besitzen ausnahmslos dieselbe Hoehe und Unterkante. Accepted Risks uebernimmt die bestehende Data-Hub-Clientgeometrie mit fester linker Auswahl `clamp(420px, 27vw, 520px)`, rechter Informationsflaeche und gestapeltem schmalem Fallback. Risiko-, Score-, Quellen-, Filter-, Drilldown- und Persistenzdaten bleiben unveraendert.
Overall-Score-Verdichtung 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die wiederholte Statuszeile unter dem Donut und der dort doppelt gezeigte Critical-Findings-Zaehler entfallen vollstaendig; Priority Findings bleibt der autoritative sichtbare Ort fuer diesen Zaehler. Assessment Coverage ist auf hoechstens 70 Prozent der Overall-Zone und zusaetzlich auf die gemeinsame kompakte Inhaltsbreite begrenzt, damit Label, Balken und Zaehler nicht von den gebogenen Domaenenverbindungen ueberlagert werden. Daten, Score, Statusableitung, Filter und Drilldowns bleiben unveraendert.
Abnahme der Overall-Score-Verdichtung: 19 fokussierte Dashboard-Tests, Designsprachpruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Messung am Dummy ergibt 354,15 px Coverage-Breite innerhalb einer 537,92-px-Overall-Zone und null Schnittpunkte mit allen neun Verbindungskurven. Posture und Aktionsrail bleiben mit jeweils 1.324,02 px exakt gleich breit und besitzen dieselbe Unterkante.
Source-Overview-Korrektur 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Die generische Quelle `Tabular Data` wird nicht mehr als Source-Overview-Kachel dargestellt. Ihre importierten Daten und unterstuetzten Filterpfade bleiben erhalten; sie ist jedoch keine eigenstaendige Security-Scoring-Quelle. Ein synthetischer Render-Test sichert die Abwesenheit der Kachel bei gleichzeitig sichtbarer erkannter Scannerquelle.
Abnahme der Source-Overview-Korrektur: 16 fokussierte Dashboard-/Quellenauswahltests, Designsprachpruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung am Dummy bestaetigt in Light und Dark null `Tabular Data`-Kacheln und weiterhin fuenf erkannte Security-Scoring-Quellen; Dark wurde als Ausgangsthema wiederhergestellt.
Accepted-Risks-Manage-Client-Uebernahme 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): Accepted Risks verwendet nun die Data-Hub-Manage-Client-Logik als feste Master-/Detail-Flaeche. Links liegen Ueberschrift, Zaehler, eigenstaendig scrollende Risikoauswahl, gelbe persistente Auswahlmarkierung sowie getrennte Status-/Reviewangaben; rechts liegen `Risk Information`, der vollstaendige ausgewaehlte Datensatz, die einzeln aufklappbaren Detailgruppen und die Aktion `Open finding`. Pfeil hoch/runter sowie Home/End wechseln Auswahl und Fokus gemeinsam; schmale Breiten stapeln beide Bereiche ohne horizontalen Ueberhang. Risikodaten, Historie und Governance-Zustaende bleiben unveraendert.
Abnahme der Accepted-Risks-Uebernahme: 18 fokussierte Dashboard-/Risk-Register-/Quellenauswahltests, Designsprachpruefung sowie Client-/Server-Produktionsbuild bestanden. Am Dummy messen Navigation und Information dieselbe 557,50-px-Hoehe; die Navigation verwendet die freigegebene 520-px-Obergrenze. `Open finding` wurde live gegen den richtigen Work-Datensatz geprueft. Light und Dark sind visuell geprueft; Client Info und Dark wurden danach wiederhergestellt.
Overall-Score-Sekundaerwerte 2026-09-20 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt): `Unassessed` und `Lowest domain pass rate` sowie ihr kompletter Trenn-/Layoutblock entfallen aus Overall Score. Die autoritativen Darstellungen bleiben unveraendert in Assessment Gaps beziehungsweise an den einzelnen Security-Domain-Knoten; Daten, Scoreberechnung und Drilldowns werden nicht geaendert.
Abnahme der Sekundaerwert-Entfernung: 19 fokussierte Dashboardtests, Designsprachpruefung sowie Client-/Server-Produktionsbuild bestanden. Die Live-Pruefung am Dummy bestaetigt in Light und Dark null Sekundaercontainer und keine beiden Labels im Overall-Bereich; `Unassessed controls` bleibt in Assessment Gaps sichtbar. Dark wurde als Ausgangsthema wiederhergestellt.
Abnahme dieser Korrektur: 12 fokussierte synthetische Dashboard-/Risikoregistertests, Client-/Server-Produktionsbuild sowie Local-only-/Designpruefung bestanden. Gesamtsuite: 1.416 bestanden, 13 optionale PostgreSQL-Tests uebersprungen; der bekannte fachfremde XML-Reader-Test mit seiner zu breiten `code`-Element-Annahme bleibt unveraendert fehlgeschlagen. Die abschliessende Breitenmessung am ausdruecklich freigegebenen Dummy bestaetigt fuer Posture und Aktionsrail exakt 1324,02 px bei 2660,02 px Gesamtbreite sowie dieselbe 744,80-px-Hoehe und Unterkante. Accepted Risks verwendet 520 px fuer die Auswahl und die restliche Breite fuer die Informationsflaeche. Light und Dark sind visuell geprueft; bei 700 px stapelt das Register ohne horizontalen Ueberhang. Bestehender Bundlegroessenhinweis und Phasenstatus bleiben unveraendert.
Client-Info-Designangleichung 2026-09-18 (Phase 7, ausdruecklicher Nutzerauftrag, umgesetzt): Die Oberflaeche verwendet nun gemeinsame Theme-/Statusrollen, eckige flache Tiles, kompakte Controls und lesbare Inter-Rollen entsprechend Data Hub, Work und Pathfinder. Die ausdruecklich gewuenschten dynamischen Kurven vom Overall Score zu den Security Domains bleiben als verbindliches Merkmal erhalten. Source Overview nutzt nicht gestreckte Kachelbreiten; seine Methodik-Schaltflaeche erbt nicht mehr versehentlich die Geometrie einer Quellenkachel. Environment Detail startet als aufklappbare Evidenzzeile; das Accepted-Risks-Register behaelt seine sichtbare Auswahl/Zusammenfassung und bietet einzeln aufklappbare vollstaendige Details. Wiederholte Null-Kontextangaben an Domaenen entfallen, positive Diagnose-/Reviewzahlen bleiben sichtbar. Daten, Scoreberechnung, Provenienz, Filter-/Drilldownidentitaeten und Risikoentscheidungen bleiben unveraendert.
Abnahme dieser Designangleichung: 47 fokussierte synthetische Dashboard-, Risk-Register-, Inventar- und Domain-Interaktionstests bestanden. Client-/Server-Typechecks, isolierter Client-Produktionsbuild und Design-/Local-only-Pruefung bestanden. Zusaetzlicher synthetischer Edge-Browsercheck in Light/Dark bei 1600, 1100, 860 und 390 px: kein horizontaler Ueberhang, eckige Tiles, exakt zentrierte Scorezahl, 28px Methodik-Control, acht Kurven mit weniger als 1px Zielabweichung in der nebeneinanderstehenden Darstellung sowie Auf-/Zuklappen per Tastatur und Reset beim Risikowechsel. Schmale Ansichten behalten den bisherigen gestapelten Fallback ohne waagerechte Kurven. Screenshots sind ausschliesslich synthetisch. Keine Kundendaten, laufenden Dienste oder regulaeren Build-Verzeichnisse veraendert; keine gesamte Projektsuite behauptet. Bestehender Bundlegroessenhinweis und Phasenstatus bleiben unveraendert.
Work-Ansichtsexport 2026-09-18 (Phase 7, ausdruecklicher Nutzerauftrag, verifiziert): Die bestehenden CSV-/MD-/HTML-Buttons exportieren jetzt alle aktuell gefilterten/sortierten Work-Treffer einschliesslich verknuepfter und eingebetteter Results statt aller markierten Eintraege ueber mehrere Blaetter. Eine gemeinsame klickzeitgebundene Projektion liefert genau zwei Inhaltsspalten, Soll-Zustand und Abweichungen, mit nativen Anleitungen, Pruefnachweisen, Originalwerten, Baselinevarianten, GPO-Quellbelegen und offen ausgewiesenen Evidenzluecken. Aktives My Workspace, Timeslot, Suche, Facetten und Drilldown bleiben wirksam. HTML ist skriptfrei, offline mit eingebettetem lokalem Inter; lange CSV-Zellen erhalten verlustfreie Fortsetzungszeilen derselben Finding-ID. Dies ist ein Ansichtssnapshot, kein Ersatz fuer die kuratierte Arbeitsgrundlage oder die vier Artefakte der Reportstrategie. Keine Daten-/Schema-/Scoring-/Importaenderung.
Abnahme und Risiko Work-Ansichtsexport: 53 fokussierte Export-/Results-Tests bestanden; nach finaler Inter-Einbettung nochmals alle 16 neuen Export-/Interaktionstests bestanden. Gesamtsuite: 1.412 bestanden, 13 optionale PostgreSQL-Tests uebersprungen, ein bekannter fachfremder XML-Reader-Test unveraendert fehlgeschlagen; keine vollstaendig gruene Gesamtabnahme behauptet. Client-/Server-Produktionsbuild sowie Local-only-/Designpruefung bestanden; App HTTP 200, Backend ok. Nur der ausdruecklich freigegebene Dummy wurde lesend geprueft, einschliesslich seiner referenzierten oeffentlichen Baseline-Anleitungen: All 1.833 Work-Treffer / 9.917 Results-Zeilen / 27.059 gepruefte Zellen / 708 Textabschnitte; Deviation 477 Treffer / 7.599 Results-Zeilen. CSV-Roundtrip vollstaendig, keine Zelle ueber Excel-Limit und keine fehlenden Result-Referenzen im Dummy. Synthetischer Browsercheck bei normaler Breite und 390 px ohne horizontalen Ueberhang. Bestehender Bundlegroessenhinweis bleibt; sehr grosse Ansichten erzeugen entsprechend grosse statische Dokumente. Phasenstatus unveraendert.
Designverankerung 2026-09-18 (Phase 7, ausdrueckliche Nutzerkorrektur): Die aktuell freigegebenen Ansichten Data Hub, Work und Pathfinder sind die verbindlichen Referenzen der Ableton-inspirierten ASSESSOR-Designsprache, ausdruecklich auch fuer Client Info, Bildentwuerfe und Prototypen. Die bisherige visuelle Client-Info-Ausnahme ist aufgehoben; fachliche Inhalte, Scoresemantik, Provenienz und Drilldowns bleiben bindend. AGENTS.md, lokaler assessor-ui-Skill, Referenzvorrang und veraltete Asset-Kopieranleitung wurden auf diesen Vertrag ausgerichtet. Status: Designvertrag verankert; die drei verworfenen generischen Mockups sind nicht freigegeben, eine entsprechende Client-Info-UI-Neugestaltung ist damit noch nicht umgesetzt oder visuell abgenommen. Bestehende Phasenstatus unveraendert; keine App-, Daten-, API- oder Persistenzaenderung in diesem Dokumentationsschritt.
Security-Domain-Korrektur 2026-09-18 (Phase 7, ausdruecklicher Nutzerauftrag): Work bietet alle acht unveraenderten Client-Info-Security-Domains in Katalogreihenfolge dauerhaft an, einschliesslich null Treffern in gemischtem Scope und beiden GPO-Einstiegen. Vorhandenes Unclassified bleibt zusaetzlich erhalten. Die Zaehler beachten andere aktive Filter; ausgewaehlte Nulltreffer bleiben sichtbar, ohne Status oder weitere gueltige Filter automatisch zu entfernen. Ein Client-Info-Domain-Deviation-Klick setzt den sichtbaren Domainfilter statt einer festen Record-ID-Auswahl, sodass nach dem Wechsel weitere Domains gewaehlt werden koennen. Vier neue synthetische UI-Interaktionstests sichern den Ein-Domain-Fall, beide GPO-Einstiege, Nulltrefferauswahl, Status-/Severity-Erhalt und Client-Info-Wechsel mit Quellfilter. 40 fokussierte Tests bestanden; abschliessende Build-/Gesamtabnahme folgt unten. Keine Klassifikations-, Score-, API-, Import- oder Persistenzaenderung; keine Mandantendaten gelesen. Phasenstatus unveraendert.
Abnahme Security-Domain-Korrektur 2026-09-18: 40 fokussierte Tests, Client-/Server-Produktionsbuild, Local-only-/Designpruefung und technische App-/Backend-Erreichbarkeit bestanden. Gesamtsuite: 1.381 bestanden, 13 optionale PostgreSQL-Tests uebersprungen; zwei Fehler (bekannte fachfremde XML-Reader-Testannahme und Timeout des allgemeinen Node-Importtests). Der isolierte Wiederholungslauf des Node-Importtests bestand mit allen fuenf Tests; Timeouts wurden nicht angehoben. Der unveraenderte XML-Test bleibt offen. Keine Kunden-/Dummy-Dateninspektion; kein Reimport, keine Neuauswertung, kein Schema-/Datenbankeingriff. Phasenstatus unveraendert.
Abschliessender Gesamtlauf zum GPO-Zaehlerfix 2026-09-18: 1.358 Tests bestanden, 13 optionale PostgreSQL-Tests uebersprungen, ein unveraenderter fachfremder XML-Reader-Test fehlgeschlagen; keine Timeout-/RPC-Fehler im Wiederholungslauf. Die fuenf neuen optionalen PostgreSQL-Paritaetstests wurden separat tatsaechlich ausgefuehrt und bestanden. Local-only-/Designpruefung separat gruen. Keine vollstaendig gruene Gesamtsuite behauptet.
Verifiziert 2026-09-18 (Phase 7/13B, Result-DB-/Work-Zaehlerabgleich): PostgreSQL waehlt aktuelle Policy-Evaluationen jetzt nach der Anzahl verschiedener tatsaechlich verglichener Baseline-Pakete, mit Analyseverweisen nur als Fallback bei fehlenden Baseline-IDs und danach Zeit/ID als Tie-Breaker, entsprechend dem bestehenden Work-Selektor. Wiederholte Analysegenerationen desselben Pakets verdraengen damit keinen neueren gleich breit abdeckenden Lauf. Work-Deviation folgt dem autoritativen Fail-Urteil, auch bei explizit erforderlicher, aber fehlender Registry-Praesenz; gewoehnliche fehlende Empfehlungen bleiben Not configured. 50 fokussierte Tests inklusive fuenf direkt in PostgreSQL ausgefuehrter rein synthetischer SELECT/CTE-Paritaetsfaelle bestanden; Client-/Server-Produktionsbuild sowie Local-only-/Designpruefung erfolgreich. Ausdruecklich freigegebener Dummy lesend geprueft: 127 aktuelle Fail-Vergleiche, exakt dieselben 25 technischen Identitaeten in Work, Deviation 25, keine ausgeblendeten Fail-Urteile und keine zusaetzlichen Vergleichspaare. Keine Bestandsmutation, Migration, Reimport oder Neuauswertung; Fremdkunden nicht gelesen. Die Gesamtsuite ist wegen einer unabhaengigen bestehenden XML-Reader-Testannahme nicht vollstaendig gruen; Details im Abnahmebericht. Phasenstatus unveraendert.
Verifiziert 2026-09-18 (Phase 7/13B, konsistente Result-DB-Statusfilter): Result DB und Work verwenden denselben Bezeichnungskatalog; Match wird Passed, Misconfigured wird Deviation, Conflict/Duplicate werden Conflicts/Duplicates und die Result-DB-Gruppe heisst Result Status. Auch Resultdetails verwenden diese Labels. Die Statuszaehler beachten Deviation und alle anderen aktiven Filter; nur die eigene Auswahl wird fuer erreichbare Alternativen ausgenommen. Statusauswahl und Abwahl setzen Deviation nicht mehr automatisch zurueck. Ausgewaehlte Status- und boolesche Nulltrefferfilter bleiben sichtbar und abwaehlbar. Zwei neue synthetische Result-DB-Interaktionstests sichern reine GPO- und gemischte Timeslots, Yes/No/All, Mehrfachauswahl, Nulltreffer, Detailbezeichnung und Scope; 45 fokussierte Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild bestanden. Gespeicherte Klassen, Einzelvergleichszaehlung und Datenschutzgrenze bleiben erhalten; keine Kunden-/Dummy-Dateninspektion. Phasenstatus unveraendert.
Verifiziert 2026-09-18 (Phase 7/13B, langer Security-Template-Registrytitel): Der vorherige Ueberhangfix erfasste nur Titel mit `registry ·`, waehrend Security-Template-Findings den Vertrag `Registry Values · MACHINE\\...` verwenden. `PathText` erkennt nun auch diesen exakten Titelvertrag und behandelt `MACHINE\\`/`USER\\` nur in diesem Registry-Values-Kontext als technischen Pfad. Dadurch umbrechen Reader-Heading und Summary innerhalb ihrer Minmax-Spalte und bleiben vor `Accepted Risk`; allgemeine AppLocker-/User-group-Texte werden nicht als Pfad umklassifiziert. Zwei synthetische Regressionen sichern den konkreten langen Titel, Suchmarkierung, beide Readerpositionen und den bewusst unveraenderten allgemeinen `MACHINE\\`-Fall. Gesamtsuite: 1.351 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Produktionsbuild erfolgreich. Keine Kunden-/Dummy-Daten gelesen oder geaendert, keine API-, SQL-, Persistenz- oder Fachlogikaenderung. Phasenstatus bleibt in Arbeit.
Verifiziert 2026-09-18 (Phase 7/13B, eine Work-Zeile je GPO-Einstellung): Mehrere Baseline-Paketvergleiche derselben technischen Identitaet werden innerhalb desselben Projekts, Aggregationslaufs und Scopes zu einer Work-Zeile zusammengefuehrt. Der bestehende Policy-Zaehler und die Zeile teilen dieselbe Urteilprioritaet. Individuelle Baselinefilter, alle Empfehlungen/Settingreferenzen, Quellprovenienz, historische Readiness sowie echte Conflict-/Duplicate-Merkmale bleiben erhalten. Wertmarkierungen pruefen alle beteiligten aktuellen Varianten; unbekannte/historische Varianten werden nicht aus dem Beweis entfernt. Alte lokale Baseline-Notizen bleiben lesbar; nur uebereinstimmende Notizen werden uebernommen, keine Risikoakzeptanz wird umgehaengt. Zehn neue synthetische Tests sichern den Neun-Pakete-Fall in beiden Work-Einstiegen, Filter, unterschiedliche Sollwerte, Historie, Scopegrenzen und Review-Erhalt. Gesamtsuite: 1.349 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Kunden-/Dummy-Daten gelesen oder geaendert, keine API-/SQL-/Persistenz-/Evaluatoraenderung. Phasenstatus bleibt in Arbeit.
GPO-Quellwiederherstellung 2026-09-17 (Phase 13B, Nutzerauftrag, verifiziert): Fehlende GPO-Artefaktdateien ergeben jetzt bereits beim Statuslesen Upload required mit gezieltem ZIP-/Ordnerupload; Zugriffsprobleme bleiben separat Storage unavailable. Reconciler, Retry und Reprocess pruefen vor dem Einreihen, nachtraeglich fehlende Quellen beenden einen bereits gestarteten Job nach einem Versuch. Der Originalupload prueft Projekt/Artefakt/Timeslot, Groesse und SHA-256, repariert auch den normalen Dublettenpfad und stellt nur fehlende Bytes atomar unter derselben Speicher-ID wieder her. Aktuelle Analysen, Originalzeitpunkte und Historie bleiben erhalten; reine Re-evaluation ueber erhaltene Eingaben bleibt erreichbar. 16 neue synthetische Regressionen sichern Service, HTTP, API-Client, beide Oberflaechen mit ZIP/Ordner, automatische Reparatur, ersten Fehler, Zugriffsfehler, falsche Quelle, fremde Scopes, Wiederherstellung ohne Revisionswechsel sowie Local-/PostgreSQL-Jobverhalten und konkurrierende Dateiwiederherstellung. npm test: 1.336 bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Kein Parser-/Analyzer-/Manifestbump, keine SQL-Migration, kein Zugriff auf Kundeninhalte, keine Live-Datenmutation und kein Backendneustart. Bestehender Bundlegroessenhinweis bleibt; Phase 13B bleibt in Arbeit.
Principal-Abweichungen mitgliedsgenau markiert 2026-09-17 (Phase 7/13B, Nutzerfehlerbericht, zielgerichtet verifiziert): Die Ursache der falschen roten Flaeche war der zuvor eingefuehrte Ganzwert-Fallback fuer nicht-Registry-GPO-Werte. Ein Principal-Set wurde dadurch trotz eines mit der Baseline uebereinstimmenden Mitglieds vollstaendig rot dargestellt. Principal-Sets werden jetzt auf Mitgliedsebene verglichen: Nur konfigurierte Principals, die in keiner aktuellen Baselinevariante erwartet werden, erhalten die rote Wertmarkierung; gemeinsame Principals bleiben neutral. Besteht die Abweichung ausschliesslich in einem fehlenden Soll-Principal, wird kein vorhandener korrekter Principal ersatzweise rot markiert. Synthetische Regressionen sichern zwei zusaetzliche, einen gemeinsamen und einen ausschliesslich fehlenden Principal sowie Suche, Filter und Navigation. 26 fokussierte GPO-Quellen-/Conflict-/Duplicate-Tests und beide Produktionsbuilds erfolgreich. Der parallele Gesamtlauf war nicht vollstaendig gruen: 1.312 Tests bestanden, acht optionale PostgreSQL-Tests wurden uebersprungen und fuenf fachlich unabhaengige Servertests trafen waehrend gleichzeitiger Security-Template-Aenderungen auf geaenderte Section-Casing-/Parser-/Analyzer-Versionen; ein aktueller fokussierter Wiederholungslauf bestaetigte den Principal-Fix und zeigte noch drei fremde Versionsannahmen (`1.1.0`/`2.2.3` gegen `1.2.0`/`2.2.4`). Reine Read-/Praesentationskorrektur ohne Reimport, Neuauswertung, API-, Persistenz-, Schema- oder Kundendatenzugriff; Phasenstatus unveraendert.
Alle belegten GPO-Wertabweichungen rot markiert 2026-09-17 (Phase 7/13B, ausdrueckliche Nutzererweiterung, verifiziert): Die rote wertgenaue Markierung ist nicht mehr auf einzelne Data-Faelle beschraenkt. Registry.pol-Quellen markieren jetzt jede belegte abweichende Operation, Data und jeden abweichenden Type code, auch mehrere Felder gleichzeitig. Vergleichbare andere GPO-Wertarten erhalten dieselbe rote Wertmarkierung. Wenn jede aktuelle Baselinevariante eine Abweichung belegt, aber keine einzelne Feldursache allen Varianten gemeinsam ist, wird sicher der dargestellte Wert als Ganzes markiert, ohne eine spezifische Ursache zu erfinden. Review-required, unbewertete und passende Werte sowie Quellenabschnitt, Heading, Evidence-Pfad und Diagnose-Badges bleiben neutral. Synthetische Regressionen sichern Operation, Data, Type code, kombinierte Feldabweichungen, skalare GPO-Werte, Suche, Filter und Navigation. 1.317 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; beide TypeScript-Ziele, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine aktuelle Read-/Praesentationsprojektion ohne Reimport, Neuauswertung, API-, Persistenz-, Schema- oder Kundendatenzugriff; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
Backend-Verbindungsstatus als ASSESSOR-Popup 2026-09-17 (Phase 7, ausdrueckliche Nutzerkorrektur, verifiziert): Die bisherige eingebettete Warnflaeche fuer `Backend is unreachable` ist in Data Hub, Result DB und DB Structure durch einen gemeinsamen ASSESSOR-Standarddialog ersetzt. Titel, Warnmarker, bereichsspezifische Auswirkung und der Hinweis auf die automatische Verbindungspruefung bleiben sichtbar; der Dialog ist schliessbar, waehrend die zugrunde liegende Ansicht erhalten bleibt. Die bestehende Health-Pruefung laeuft unveraendert weiter und entfernt den Offline-Zweig nach erfolgreicher Verbindung automatisch. Permanente synthetische Regressionen pruefen Portal-/Dialogstil, Meldung, Schliessen und alle drei Einstiegspfade. 1.313 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; beide TypeScript-Ziele, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine Praesentationsaenderung ohne API-, Import-, Persistenz-, Schema-, Reimport- oder Kundendatenzugriff; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
Client-Info-Security-Domains als Work-Filter 2026-09-17 (Phase 7, ausdrueckliche Nutzererweiterung, verifiziert): Work besitzt jetzt direkt nach `Category` die eigene Facette `Security Domain`. Sie verwendet denselben extrahierten Katalog und dieselbe gecachte Datensatzzuordnung wie die acht Security Domains in Client Info; es existiert keine zweite Filterklassifikation. Damit filtern unter anderem `Identity & Account Hygiene`, `System Hardening`, `GPO & Baseline` und `Monitoring & Recovery` exakt die von Client Info derselben Domain zugeordneten Datensaetze. Die Facette ist im gemischten Work-Kontext sowie in beiden GPO-Einstiegen sichtbar; ein reiner GPO-Scope zeigt `GPO & Baseline`. `Source: Scan` bleibt der letzte Filter und bestehende aktive Work-Filter gelten weiter. Permanente synthetische Regressionen sichern gemeinsamen Katalog, identische Record-Identitaeten, Filterwirkung, GPO-Einwertscope, Reihenfolge, Zaehler und Kontextwechsel. 1.307 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; beide TypeScript-Ziele, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine gemeinsame Ableitungs-/Facettenkorrektur ohne Import-, Persistenz-, Schema-, Reimport- oder Kundendatenzugriff; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
GPO-Category-Filter wiederhergestellt 2026-09-17 (Phase 7/13B, Nutzerfehlerbericht und ausdrueckliche Korrektur, verifiziert): Die Ursache war die fruehere GPO-Kontext-Sonderregel, die `Category` sowohl bei `Source: Scan = GPOs` als auch auf dem dedizierten GPO-Blatt aktiv ausblendete. `Category` ist jetzt in beiden unterstuetzten GPO-Einstiegen wieder der permanente zweite Filter direkt hinter `Result Status`; er bleibt auch sichtbar, wenn `GPOs` der einzige aktuelle Kategorienwert ist. Im gemischten Work-Kontext bleibt die bisherige Category-Facette unveraendert. Andere im reinen GPO-Kontext ausgeschlossene Scannerfacetten bleiben ausgeschlossen und `Source: Scan` bleibt der letzte Filter. Permanente synthetische Regressionen sichern beide GPO-Einstiege, die exakte Reihenfolge, GPO-exklusive Zaehler, die Einwertkategorie, Kontextwechsel und Filterwirkung. 1.306 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; TypeScript-, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine Facetten-/Praesentationskorrektur ohne Import-, Bewertungs-, Persistenz-, Schema-, Reimport- oder Kundendatenzugriff; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
GPO-Abweichung nur am Wert 2026-09-17 (Phase 7/13B, ausdrueckliche Nutzerkorrektur, verifiziert): Die rote Flaeche und Akzentlinie am gesamten GPO-Quellenabschnitt sind entfernt. Jeder Quellenabschnitt bleibt neutral; rot hinterlegt wird ausschliesslich die konkrete dargestellte Zeile, deren Feld gegen jede gespeicherte aktuelle Baselinevariante eindeutig abweicht: `Operation` bei einer Operationsabweichung, `Data` bei einer Datenabweichung und `Type code` bei einer Typabweichung. Mehrere Felder werden nur markiert, wenn jedes unabhaengig belegt ist; bei uneinheitlicher Variantenursache wird kein Feld geraten. Conflict-Badge, Heading, Evidence, Key und Value name erhalten keine rote Hinterlegung. Synthetische Regressionen pruefen `Operation: delete-value`, `Data: 0`, einen reinen Typfehler, exakt eine Markierung im Finding sowie Suche, Filter und Navigation. 1.305 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; TypeScript-, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine aktuelle Read-/Praesentationsprojektion ohne Reimport, automatische Neuauswertung, Migration oder Kundendateninspektion; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
GPO-Quellenabschnitte mit roter Abweichung 2026-09-17 (Phase 7/13B, ausdrueckliche Nutzerkorrektur, verifiziert): Die unmittelbar zuvor gewaehlte flache Tabelle ist ausdruecklich wieder durch vollstaendige, untereinanderliegende GPO-Quellenabschnitte ersetzt. Evidence, quelllokale Sequenz sowie Operation, Key, Value name, Data und Type code sind ohne Aufklappen sichtbar; Operation und Data bleiben fett. Nur ein durch die gespeicherten aktuellen Baselinevarianten fuer diesen einzelnen Quellwert eindeutig belegter abweichender Abschnitt erhaelt eine dezente rote Flaeche, eine rote linke Akzentlinie und `Conflicting contribution`. Passende, historische, mehrdeutige oder innerhalb eines Backups aus mehreren Anweisungen bestehende Quellen bleiben neutral; GPO-Praezedenz und effektiver Endgeraetewert werden nicht erfunden. Permanente synthetische Regressionen sichern Darstellung, rote Quellenklasse, vollstaendige Fakten, beide Work-Einstiege, Deviation/Conflicts, Suche, Navigation und unveraenderte Originale. 1.305 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; TypeScript-, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine aktuelle Read-/Praesentationsprojektion ohne Reimport, automatische Neuauswertung, Migration oder Kundendateninspektion; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
GPO-Quellenzuordnung als Tabelle 2026-09-17 (Phase 7/13B, ausdrueckliche Nutzerentscheidung, verifiziert): Die zuvor gestapelten Quellenabschnitte sind im Finding Reader durch die gewaehlte flache Tabelle mit exakt `GPO source`, `Operation`, `Data`, `Type` und `Evidence` ersetzt. Jede exakte Backupidentitaet belegt eine Zeile; Operation und Data bleiben fett, Data und Typ stehen in kompakten Feldern, und der sichere Evidence-Pfad bleibt unmittelbar sichtbar. `Show source details` erhaelt vollstaendige native GPO-/Backupidentitaet, quelllokale Sequenz, Evidence, Key, Value name und jede Originalanweisung. Ein roter Marker und `Conflicting contribution` erscheinen nur, wenn die gespeicherten aktuellen Baselinevarianten den einzelnen Quellwert unabhaengig als abweichend belegen; Gruen nur bei Uebereinstimmung mit allen Varianten. Historische Auswertungen, mehrere Anweisungen innerhalb desselben Backups, GPO-Praezedenz und effektiver Endgeraetewert werden nicht als Einzelquellenurteil erfunden. Permanenter synthetischer Interaktionstest sichert beide Work-Einstiege, Deviation/Conflicts, Suche, Navigation, die fuenf Spalten, Details und unveraenderte Originale. 1.305 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; TypeScript-, Local-only-, Design- sowie Client-/Server-Produktionsbuild erfolgreich. Reine aktuelle Read-/Praesentationsprojektion ueber erhaltene Fakten, ohne Reimport, automatische Neuauswertung, Migration oder Kundendateninspektion; Phasenstatus unveraendert. Bestehender Bundlegroessenhinweis bleibt.
GPO-Quellenzuordnung 2026-09-17 (Phase 7/13B, ausdruecklicher Nutzerauftrag, verifiziert): Der Finding Reader zeigt Originalanweisungen sofort getrennt nach exakter Projekt-/Assessment-/Backupinstanzidentitaet, mit Quellenzahl, erhaltenen nativen GPO-/Backup-IDs, internem Quellpfad und quelllokaler Sequenz. Operation und Data bleiben fett; Loeschung, leerer String, Leerzeichen und 0 bleiben unterscheidbar. Mehrere GPOs werden nicht als Ausfuehrungskette oder wirksamer Endgeraetewert dargestellt. Die unmittelbare Quellenzuordnung ersetzt die bisherige Results-only-Verbergung; Results bleibt fuer Varianten erhalten. Die gemeinsame Local-/PostgreSQL-Readprojektion reicht nur bereits gespeicherte Identitaeten/Sequenzen additiv weiter; lesbare GPO-Namen sind nicht gespeichert und werden nicht erfunden. Fehlerursache der fehlenden Conflict-Kennzeichnung: Die alte Ableitung beruecksichtigte nur set-value-Beobachtungen. Versionierte Vergleiche verwenden jetzt den erhaltenen autoritativen customerState; fruehere Anweisungen derselben GPO erzeugen keine zusaetzliche Konfliktzeile und reduzierte identische Beitraege mehrerer Backups bleiben Duplicate. Bei indeterminate bleiben nur durch verschiedene eindeutige Backups und erhaltene Vergleichswerte belegte Unterschiede als Conflict sichtbar; unsichere Backups liefern dafuer keine Einzelwertbeweise. Urteil, Score und historische Re-evaluation-Grenze bleiben erhalten. 20 neue synthetische Regressionen sichern beide Work-Einstiege, Deviation/Conflicts, Suche, Quellenzuordnung, Datensatzwechsel, Identitaeten, Sequenz 0, partielle Konfliktbeweise und unveraenderte Originale. 1.304 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. App und Backend inhaltsfrei gesund geprueft. Kein Reimport, keine automatische Neuauswertung, Datenmigration oder Kundendateninspektion; Projekt zum erneuten Laden der Readprojektion neu oeffnen. Restrisiko: GPO-Namen fehlen weiterhin, Geltungsbereich/Praezedenz und effektiver Endgeraetewert bleiben unbelegt; bestehender Bundlegroessenhinweis. Phasenstatus unveraendert.
GPO-Wertdarstellung 2026-09-17 (Phase 7, Nutzerwunsch, verifiziert): Operation und Data samt Werten werden im Policy-Vergleich des Finding Readers mit dem gemeinsamen Bold-Token 700 hervorgehoben. Pfadtextboxen, Suchmarkierung, Originaltext und Bewertungslogik bleiben erhalten. Bestehende synthetische Readerregression erweitert; 1.284 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Reine Darstellung ohne Reimport oder Neuauswertung; kein neuer fachlicher Risikoeingriff und Phasenstatus unveraendert. Die Reihenfolge dargestellter Quellen ist weiterhin kein Nachweis einer GPO-uebergreifenden Ausfuehrungsreihenfolge.
GPO-Quellenfilter 2026-09-17 (Phase 7/13B, ausdruecklicher Nutzerauftrag, verifiziert): Work ergaenzt im GPO-Blatt und bei Source: Scan = GPOs den Filter `GPO backups with failing settings`, mit Gesamtauswahl und einzelnen GPO-Backupinstanzen. Entscheidung: Filter und Processing-Kachel verwenden denselben exakten Projekt-/Assessment-/Backupinstanzschluessel und dieselben Quellen der Fail-Vergleiche; es werden keine Einzel-GPO-Urteile erfunden. Die Auswahl setzt Result Status explizit auf All, damit historische Fail-Vergleiche trotz ihres erhaltenen Review-required-Status sichtbar bleiben; weitere Filter gelten weiter. Source: Scan bleibt zuletzt, inkompatible Filter verschwinden beim Kontextwechsel, native Optionen werden erst vor Pointer-/Tastaturzugriff vollstaendig montiert. Vier neue synthetische Tests sichern 26 Quellen jenseits des allgemeinen Kategorienlimits, Einzel-/Gesamtauswahl, den passenden Mitgliedswert eines gemeinsamen Fail, beide Work-Einstiege, Historie, Statuskombination, Reset, Kontextwechsel, exakte Identitaeten und Cacheinvalidierung. 1.284 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Restrisiko/Lesart: Quellenbeteiligung ist kein eigener Fail jeder GPO; Work ist auf den geladenen Scope begrenzt, die Processing-Kachel bleibt projektweit. Reine Darstellung ueber erhaltene Fakten, kein Reimport, keine Neuauswertung, Migration oder Kundendateninspektion. Phasenstatus bleibt unveraendert; bestehender Bundlegroessenhinweis bleibt.
GPO-Registry-Zustandsvergleich 2026-09-16 (Phase 7/13B, ausdrueckliche Nutzerentscheidung): Evaluator 1.9.0 behandelt `delete-value`, `delete-all-values`, `create-key` und typisierte leere Zeichenfolgen als ausdrueckliche Sollzustaende statt als `Baselinewert nicht verfuegbar`. Delete-Anforderungen bestehen bei Abwesenheit beziehungsweise passender Delete-Instruktion und schlagen bei einem gesetzten Ziel fehl; Delete-all beruecksichtigt konfigurierte Kindwerte desselben Schluessels. Create-key besteht bei expliziter Anlage oder durch konfigurierte Kindwerte belegter Schluesselanwesenheit und schlaegt bei Abwesenheit fehl. Leere REG_SZ/REG_EXPAND_SZ bleiben vorhandene, typisierte Werte. Schluesselvergleiche speichern alle beitragenden Aggregationszustands- und Beobachtungsreferenzen. Die Regeln gelten nur fuer die analysierte Backup-Konfiguration, nicht fuer effektiven Geraetezustand. Historische Laeufe bis 1.8.0 bleiben unveraendert und benoetigen eine ausdrueckliche Re-evaluation; kein Quelldatei-Reimport, Processing-Manifest- oder SQL-Schemawechsel. 32 fokussierte Comparator-/Evaluatorregressionen sowie exakte Work-Statuspruefungen bestanden; Gesamtlauf mit 1.280 bestandenen Tests und acht uebersprungenen optionalen PostgreSQL-Integrationstests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phasenstatus bleibt in Arbeit.
GPO-Not-configured im analysierten Backup-Scope 2026-09-16 (Phase 7/13B, Nutzerfehlerbericht): Der Result-Status-Filter war vorhanden, erhielt jedoch fuer einen ausgewaehlten beziehungsweise technisch partiellen GPO-Backupsatz keine Treffer, weil Evaluator 1.7.0 jede dort nicht beobachtete anwendbare Empfehlung pauschal als `Review required` sperrte. Evaluator 1.8.0 klassifiziert diese Abwesenheit jetzt als score-neutrales `Not configured` innerhalb genau des analysierten Backupsatzes und erhaelt `policy-evaluation-customer-absence-scope-limited` als sichtbare Diagnose. Das ist weder eine Aussage ueber die gesamte Domaene noch ueber den effektiven Geraetezustand. Work projiziert das Urteil in den autoritativen Status; die direkte Nutzerinteraktion `Result Status = Not configured` liefert die Zeile und `Deviation` nicht. Historische Evaluationen bis 1.7.0 werden nicht still umgedeutet: Data Hub bietet dafuer die bestehende ausdrueckliche Re-evaluation der erhaltenen Eingaben an; kein Quelldatei-Reimport, Processing-Manifest- oder Schemawechsel. 56 fokussierte GPO-Bewertungs-, Projektions-, Filter- und Panelregressionen sowie die exakte Work-Interaktion bestanden. Vollstaendiger Lauf mit 1.277 bestandenen Tests und acht uebersprungenen optionalen PostgreSQL-Integrationstests, Local-only-/Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Phasenstatus bleibt in Arbeit.
GPO-Analyse-Zaehlebenen bereinigt 2026-09-16 (Phase 7/13B, Nutzerfehlerbericht): Die Data-Hub-GPO-Analyse mischt keine Analyse-Laeufe, Baseline-Vergleichszeilen, technischen Einstellungen, Backups und Diagnoseereignisse mehr in einer scheinbar addierbaren Ergebnisgruppe. `Policy verdicts` zaehlt je Aggregation/Timeslot und technischer Policy-Identitaet genau ein Urteil mit der Prioritaet Fail vor Review required vor Pass vor Not configured; damit koennen mehrere Baseline-Vergleiche derselben Einstellung die fachliche Zahl nicht mehr vervielfachen. Conflict und Duplicate stehen separat als ueberlappende, score-neutrale `Diagnostic flags`. `Analysis gaps` trennt Unresolved identity, Comparison not supported und Baseline mapping unavailable. Die irrefuehrende Bezeichnung `GPO sources` fuer einen Analyse-Lauf sowie der zusammengeworfene Wert `Unsupported settings` wurden entfernt; GPO-Backups mit fehlgeschlagenen Einstellungen stehen als eigene Provenienzkennzahl. Technische Einzelereignisse bleiben in Review Queue und Evaluation History nachvollziehbar, ohne als Findings ausgegeben zu werden. 28 fokussierte GPO-Panel-, Rollup-, Overview- und Statusregressionen, vollstaendiger Lauf mit 1.275 bestandenen Tests und acht uebersprungenen optionalen PostgreSQL-Integrationstests, Local-only-/Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Reine Read-/Praesentationsableitung ueber erhaltene Fakten; kein Reimport, keine Bestandsmutation, Schema- oder Manifestaenderung. Phasenstatus bleibt in Arbeit.
GPO-Verdict mit Diagnose-Badge 2026-09-16 (Phase 7/13B, Nutzerpraezisierung): Work zeigt das fachliche Policy-Urteil weiterhin als primaeren Status und ergaenzt Conflict/Duplicate additiv. Ein fehlgeschlagener Eintrag bleibt deshalb in Status, Filter und Score `Fail`/`Deviation`; in der linken Trefferliste und im geoeffneten Finding steht daneben das sichtbare Badge `CONFLICT` beziehungsweise `DUPLICATE`. Die Diagnose ersetzt das Urteil nicht und erzeugt keinen zweiten Scorebeitrag. Explizit negative Diagnosefelder wie `No`, `None`, `false` oder `0` erzeugen kein Badge. 61 fokussierte Finding-, Reader-, Badge- und GPO-Work-Regressionen sowie Client-/Server-Produktionsbuild erfolgreich; reine Darstellung ueber bereits projizierte Felder, daher kein Reimport, keine Neuauswertung, Schema- oder Manifestaenderung. Phasenstatus bleibt in Arbeit.
GPO-Policy-Rollup bei Conflicts/Duplicates 2026-09-16 (Phase 7/13B, ausdrueckliche Nutzerpraezisierung, umgesetzt): Mit `Endpunkt` ist die zusammengefasste technische Policy-Einstellung gemeint, nicht ein Server oder Client. Evaluator 1.7.0 vergleicht die erhaltenen konfigurierten Mitgliedswerte eines Kundenkonflikts einzeln. Mindestens ein unabhaengig vergleichbarer In-Scope-Mitgliedswert mit belastbarem Fail macht die gesamte Policy-Einstellung einmalig zu Fail; ein ungeklaertes Mitglied blockiert nur, wenn kein belastbarer Fail vorliegt. Work fuehrt Verdict und Conflict-/Duplicate-Flags getrennt, projiziert keine zweite Diagnosezeile fuer dieselbe ausgewertete Einstellung und laesst dieselbe Zeile zugleich auf Deviation und Conflict/Duplicate filtern. Dashboard-Score und Policy-Impact zaehlen nur das Verdict; Diagnosezaehler werden je technischer Identitaet dedupliziert. `Not configured` bleibt score-neutral. Historische Laeufe bis 1.6.0 bleiben unveraendert und werden ueber den bestehenden Data-Hub-Pfad zur ausdruecklichen Re-evaluation angeboten; kein Scanner-Reimport, Schema- oder Processing-Manifestwechsel. 51 fokussierte Bewertungs-, Work-, Filter- und Duplicate-Regressionen sowie Client-/Server-Produktionsbuild, Local-only- und Designpruefung erfolgreich. Im Gesamtlauf bestanden 1.270 Tests unmittelbar; die eine geaenderte Duplicate-Regression und der bekannte lastabhaengige Node-Import-Timeout bestanden danach isoliert (14/14), acht optionale PostgreSQL-Integrationstests blieben uebersprungen. App und Backend wurden anschliessend gegen den gesunden kanonischen PostgreSQL-Datastore gestartet und antworten auf 5173/4173 mit HTTP 200. Phasenstatus bleibt in Arbeit.
GPO-Misconfiguration-Scope und score-neutrale Kontextkategorien 2026-09-16 (Phase 7/13B, ausdrueckliche Nutzerentscheidung): Die normale GPO-Analyse bewertet ausschliesslich tatsaechlich konfigurierte, technisch vergleichbare Kundeneinstellungen im ausdruecklich anwendbaren Microsoft-Baseline-Scope. Eine empfohlene, aber fehlende Einstellung bleibt als `Not configured` sichtbar, erzeugt jedoch weder operatives Finding, Standard-Remediation noch Scoreabzug; eine vollstaendige Baseline-Einfuehrung ist ein separat beauftragter Hardening-Track. `Not configured`, `Duplicates` und `Conflicts` bleiben eigenstaendige score-neutrale Kategorien ausserhalb von Pass und Fail. Ausnahme: Ein konfigurierter Einzelwert innerhalb eines Duplicate-/Conflict-Containers, der bei eindeutiger Identitaet und Baseline fuer sich belastbar fehlschlaegt, zaehlt genau einmal als Fail; Quellmultiplikation und Containerstatus erzeugen oder vervielfachen keinen Fail. Der GPO-Score ist als Konformitaet der konfigurierten, vergleichbaren In-Scope-Einstellungen zu bezeichnen, niemals als vollstaendige Microsoft-Baseline-Compliance. Diese Festlegung ist in AGENTS.md, SCORING_MODEL.md und GPO_BASELINE_EVALUATION.md bindend dokumentiert. Dokumentations-/Vertragsentscheidung ohne Laufzeit-, Daten-, Persistenz-, Schema- oder Bestandsneuauswertung; die Einzel-Fail-Ausnahme muss vor Implementierungsabnahme durch deduplizierte Ergebnisidentitaet, synthetische Conflict-/Duplicate-Fixtures und gegebenenfalls neue Evaluator-/Scoringversion verifiziert werden. Phasenstatus bleibt in Arbeit.
Work-Filter bei Source All 2026-09-16 (Phase 7, Nutzerfehlerbericht): `Source: Scan = All` zeigt wieder den gemischten Scannerkontext. GPO-exklusive Facetten wie Baseline, Comparison, Conflict, Description source, Diagnostics, Duplicates, Imported, Policy section und Policy type werden dort nicht mehr in die allgemeine Zehnerauswahl aufgenommen und koennen deshalb die Scannerfacetten nicht mehr verdraengen. Der vollstaendige GPO-Filtersatz bleibt unveraendert auf dem GPO-Blatt und bei einer eindeutigen GPO-Quellenauswahl verfuegbar. Beim Wechsel von GPOs zurueck zu All werden nicht mehr sichtbare kontextspezifische Auswahlen entfernt, sodass kein GPO-Filter unsichtbar weiterwirkt. Ein permanenter synthetischer Interaktionstest sichert den exakten Nutzerpfad `Source: Scan = GPOs` -> `Source: Scan = All`; 22 fokussierte Filter-/Interaktionstests, 1.272 Gesamttests, acht uebersprungene optionale PostgreSQL-Integrationstests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Reine Praesentations-/Filterzustandskorrektur ohne Daten-, Import-, Bewertungs-, API-, Persistenz-, Schema-, Manifest- oder Reimportaenderung; Phasenstatus unveraendert.
Work-Ansichtssnapshots 2026-09-16 (Phase 7, Nutzerfehlerbericht): Ein Work-Snapshot speichert wieder die vollstaendig wirksame, gerade angezeigte Ansicht. Neben Blatt, Darstellungsmodus, Suche, Sortierung, Result Status, Facetten, My Workspace und aktueller Auswahl gehoert nun auch der durch Client-Info-/Pathfinder-Drilldowns gesetzte Finding-Scope zum gespeicherten Ansichtsstand; beim Oeffnen wird eine unabhaengige Kopie dieses Scopes atomar wiederhergestellt. Vorhandene Snapshots ohne Scopefeld bleiben lesbar und bedeuten wie bisher keine zusaetzliche Finding-Einschraenkung. Ein permanenter synthetischer Interaktionstest sichert den exakten Nutzerpfad Snapshot -> Name -> Save -> gespeicherten Snapshot oeffnen. Acht fokussierte Tests und Client-/Server-Produktionsbuild erfolgreich. Im Gesamtlauf bestanden 1.270 Tests, acht optionale PostgreSQL-Integrationstests wurden uebersprungen; der bekannte kombinierte Node-Importtest ueberschritt unter Gesamtlast einmal sein unveraendertes Fuenf-Sekunden-Limit und bestand isoliert mit 5/5 Tests in 4.709 ms. Local-only-/Designpruefung erfolgreich. Reine lokale Ansichtsstatuskorrektur ohne Daten-, Import-, Bewertungs-, Backend-, Persistenzschema-, Processing-Manifest- oder Reimportaenderung; Phasenstatus unveraendert.
Work-Standardfilter 2026-09-15 (Phase 7, ausdrueckliche Nutzerentscheidung): `Result Status` startet beim normalen Einstieg in Work, beim Projekt-/Timeslot-/Blattwechsel, nach Importabschluss und nach Reset immer mit `Deviation`. Der bisherige GPO-Sonderfall mit automatischem `All` ist damit ausdruecklich ersetzt. Kommt der Einstieg dagegen aus Client Info, Pathfinder oder einem anderen Drilldown mit bereits gesetztem Such-, Facetten-, Pfad- oder Action-Filter, bleibt dieser Kontext einschliesslich eines dort bewusst gesetzten Status erhalten. Die normale Work-Navigation unterscheidet diese beiden Pfade jetzt explizit; eine GPO-Quellenauswahl setzt den Status nicht mehr eigenmaechtig auf `All`. Permanente synthetische Interaktionstests sichern normalen Einstieg, Client-Info-Filteruebergabe, GPO-Quelle, GPO-Blatt und expliziten GPO-Drilldown. Verifiziert mit 1.270 bestandenen Tests und acht uebersprungenen optionalen PostgreSQL-Integrationstests, Local-only-/Designpruefung sowie erfolgreichem Client-/Server-Produktionsbuild. Reine Zustands-/Navigationskorrektur ohne Daten-, Import-, Bewertungs-, Persistenz-, Schema-, Manifest- oder Reimportaenderung.
GPO-Importstatus ohne manuelle Review-Aufforderung 2026-09-15 (Phase 7/13B, ausdrueckliche Nutzerkorrektur): Ein technisch partieller GPO-Verarbeitungslauf wird im Timeslot wieder als `Limited analysis` dargestellt. Er fordert weder zur manuellen Einzelpruefung hunderter GPOs auf noch erzeugt er daraus eine automatische Arbeitsaufgabe. Damit gilt die bereits am 2026-09-06 festgelegte Trennung zwischen gelber technischer Analysegrenze und echtem Handlungsbedarf erneut; die zwischenzeitliche Beschriftung `Partial · review required` war eine irrefuehrende UI-Regression und ist durch diese Entscheidung ersetzt. Die fail-closed Fachgrenze bleibt unveraendert: Nicht vollstaendig automatisch auswertbare Bereiche werden nicht als vollstaendig bewertet, aber auch nicht als manuelle Pflicht dargestellt. Exakte synthetische Renderregression prueft `Limited analysis` sowie die Abwesenheit von `review required` und `Imported`; 17 fokussierte Status-/Readiness-Tests, Gesamtlauf mit 1.268 bestandenen und acht uebersprungenen optionalen PostgreSQL-Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, Import-, Bewertungs-, Persistenz-, Schema-, Manifest- oder Reimportaenderung.
GPO-Analysezaehler 2026-09-15 (Phase 7/13B, Nutzerfehlerbericht): Die Ergebniszusammenfassung in Data Hub bindet ihre Vergleichsprojektion nun wie der Timeslot-Workspace an die vom Backend als aktuell ausgewiesenen Aggregationslaeufe. Ein historisch aufbewahrter Evaluationslauf aus einer ueberholten Aggregation kann dadurch nicht mehr gleichzeitig mit dem aktuellen Lauf in Misconfigured, Pass oder Not configured eingehen; die Historie bleibt ausschliesslich unter den technischen Evaluationsdetails sichtbar. `Review required` wird in der Baseline-Zusammenfassung explizit ausgewiesen, damit ein partieller GPO-Backupsatz und nicht belegte Abwesenheit nicht als bestaetigtes Not configured erscheinen. Permanente synthetische Komponentenregression bildet exakt einen Timeslot mit aktueller und historischer Aggregation ab. 26 fokussierte GPO-/Workspace-Tests und Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 1.267 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; der bekannte kombinierte Node-Importtest ueberschritt unter Gesamtlast einmal sein unveraendertes Fuenf-Sekunden-Limit und bestand isoliert mit 5/5 Tests in 3.885 ms. Bestehender Bundlegroessenhinweis. Reine Read-/Praesentationskorrektur ohne Persistenz-, Bewertungs-, Import-, Schema-, Manifest- oder Reimportaenderung; keine Kundendatenmutation.
Timeslot-Importlast 2026-09-15 (Phase 13 sowie Persistenz aus Phase 9-11): Ein gemeldeter Scannerimport wurde ausschliesslich ueber technische Audit-, Laufzeit- und Prozesssignale untersucht. Die Vorschau war nach 4,4 Sekunden abgeschlossen, waehrend der atomare Abschluss 200,3 Sekunden benoetigte. Gleichzeitig erzeugten dauerhaft montierte Data-Hub-Steuerelemente in fuenf Minuten 270 vollstaendige Result-Facettenabfragen und 326 GPO-Statusabfragen; auch reine Timeslot-Importknopfe mit `showStatus=false` pollten alle zwei Sekunden, und der globale Fuenf-Sekunden-Poll berechnete die Oeffnungsbereitschaft fuer alle Timeslots ohne Zustandsaenderung neu. Inaktive Importsteuerelemente fragen nun keinen Status ab, sichtbare Statusbereiche pollen nur bei queued/running, der Data Hub pollt nur Projekte mit laufenden GPO-Jobs und behaelt unveraenderte Status-/Readiness-Objekte. Teure Facetten werden erst nach einer wirklichen GPO-Statusrevision neu berechnet. Der PostgreSQL-Commit verwendet zusaetzlich einmalig aufgebaute Datei-/Definitionsindizes statt wiederholter linearer Suchen je Finding; Transaktion, Originalwerte, Klassifikation und Schema bleiben unveraendert. Permanente synthetische Interaktionstests sichern null Statusabfragen eines inaktiven Timeslot-Importknopfs und genau einen Abruf eines sichtbaren abgeschlossenen Status. 30 fokussierte Import-/Persistenz-/UI-Tests und beide TypeScript-Pruefungen erfolgreich. Gesamtlauf: 1.266 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; der bekannte kombinierte Node-Importtest ueberschritt unter Gesamtlast einmal sein unveraendertes Fuenf-Sekunden-Limit und bestand isoliert in 3.887 ms. Local-only-/Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Kundendateninspektion, Datenmutation, Migration, Processing-Manifest- oder Reimportaenderung. Restrisiko: Die relationale Persistenz sendet Datensaetze weiterhin einzeln an PostgreSQL; falls ein ruhiger synthetischer Lasttest nach dieser Lastkorrektur noch einen relevanten Commitanteil zeigt, ist gebuendeltes Schreiben ein eigener vertragserhaltender Folgeschritt.
Verifikation GPO-Verarbeitungskorrektur 2026-09-15: 1.265 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-Sicherheitspruefung, Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Die fokussierten Tests sichern 65 Bewertungs-, Versions-, Work- und Statusfaelle. Das laufende Backend meldet fuer den betroffenen 184-GPO-Lauf technisch `partial` und bietet nach dem Enginewechsel genau ein Update von 1.5.0 auf 1.6.0. Read-only-Browserpruefung zeigt im betroffenen Timeslot `Re-evaluation required` mit dem vorhandenen `Re-evaluate`-Bedienelement. Es wurde nicht ausgeloest; keine Kundendatenmutation.
GPO-Verarbeitung statt globalem Review 2026-09-15 (Phase 7/13B): Der gemeldete Clientzustand wurde ausschliesslich ueber technische Status-, Zaehler- und Diagnosecodes untersucht. Der GPO-Import enthielt 184 Backupinstanzen und stand serverseitig auf partial, waehrend Data Hub irrefuehrend nur Imported anzeigte. Evaluator 1.5.0 versah alle aktuellen Vergleiche mit `policy-evaluation-baseline-incomplete`, weil globale Baselineparser-Diagnosen aus Security Templates auch separat aufgeloeste Registry-Policy-Kandidaten blockierten. Evaluator 1.6.0 begrenzt partielle Parserdiagnosen auf den betroffenen Bereitstellungsmechanismus: korrekt gelesene Registry-Werte bleiben vergleichbar, Security-Template-Lesefehler blockieren weiterhin Security-Template-Kandidaten, und unbekannte Diagnosen bleiben fail-closed. Fehlende Kundenwerte bleiben ohne nachgewiesenen vollstaendigen Backupsatz Review required; es werden keine Abwesenheit oder Abweichung erfunden. Data Hub bezeichnet einen partiellen GPO-Lauf jetzt sichtbar als `Partial · review required` statt `Imported`. Permanente synthetische Regressionen sichern Pass und Deviation fuer den unabhaengigen Registry-Pfad, die bestehenden Sicherheitsgrenzen und den gerenderten partiellen Status. Historische 1.5.0-Laeufe bleiben unveraendert und werden durch den bestehenden Data-Hub-Versionspfad zur ausdruecklichen Re-evaluation angeboten. Keine automatische Bestandsneuauswertung, Datenmutation, Quelldateiinspektion, SQL-Migration oder Scanner-Reimportaenderung; Phasenstatus unveraendert.
Verifikation Dummy-Anzeigekorrektur 2026-09-14: Abschliessend `npm test` mit 1.263 bestandenen und acht optional uebersprungenen PostgreSQL-Tests; Local-only-/Designpruefung sowie `npm run build` fuer Client und Server erfolgreich. Zwei zuvor fehlgeschlagene Quelltext-Strukturpruefungen wurden an die durch Verhaltenstests gesicherte neue GPO-Navigation angepasst; ein Import-Test-Timeout im ersten Gesamtlauf bestand isoliert und im unveraenderten abschliessenden Gesamtlauf. App, Entwicklungsserver und Backend antworten mit HTTP 200; die App liefert das neu gebaute Frontend aus. Live-Browserkontrolle ausschliesslich am freigegebenen Dummy, keine anderen Clientinhalte gelesen; temporaerer Prueftab geschlossen.
GPO-Anzeige am freigegebenen Dummy 2026-09-14 (erneute Nachkorrektur, Phase 7/13B): Die vorherige Anzeige-Nachkorrektur erfasste nur die GPO-Quellkachel, nicht den normalen Source-Dropdown. Der ausdruecklich freigegebene Dummy bestaetigte den verbleibenden Fehler: Client Source GPOs -> Work behielt Deviation und zeigte null Treffer. Die gemeinsame App-Zustandsgrenze initialisiert eine neue eindeutige GPO-Source-Auswahl sowie ein GPO-Blatt jetzt mit All; spaetere ausdrueckliche Statusauswahlen bleiben auch bei null Treffern erhalten. Eine Domain mit ausschliesslichem Pruefbedarf zeigt dessen Anzahl und oeffnet genau diese gespeicherten Ergebnisse; vorhandene Deviation-Drilldowns bleiben unveraendert. Live am Dummy verifiziert: beide Source-Auswahlwege zeigen 3.873 GPO-Datensaetze; der Domain-Link oeffnet 3.872 Review-Ergebnisse; Passed bleibt gezielt mit null Treffern waehlbar. Die gespeicherte aktuelle Evaluation 1.5.0 enthaelt selbst Review-required-Urteile mit unvollstaendiger Baseline-Evidenz, nicht nur historische Urteile. Daher werden weder aktuelle Pass-/Fail-Urteile noch ein Score aus alten Laeufen rekonstruiert. Vier permanente synthetische Interaktionstests mit echtem AppProvider sichern Client-Dropdown, Work-Dropdown, GPO-Blatt und Domain-Link einschliesslich expliziter Nulltreffer-Auswahl. Nur Praesentation/Navigation; keine Neuauswertung, Datenmutation, Import-/Reimport- oder Bewertungsregel-Aenderung. Die bisherigen Verifikationsaussagen waren fuer den normalen Dropdown-Pfad unzureichend; Phasenstatus unveraendert.
GPO-Anzeige nach Evaluatorwechsel 2026-09-14 (Nachkorrektur, Phase 7/13B): Der anschliessende Nutzer-Screenshot widerlegte die vorherige Aussage, die Timeslotkorrektur habe den gemeldeten Anzeigeausfall vollstaendig behoben. Geladene historische GPO-Vergleiche wurden als Review required zurueckgehalten, im Dashboard aber als fehlende Evidenz gezaehlt; der Source-Drilldown setzte Deviation und fuehrte damit in eine leere Work-Ansicht. Client Info unterscheidet jetzt ausstehende Neuauswertung, aktuellen manuellen Pruefbedarf und fehlende Evidenz. Die GPO-Quellkachel oeffnet All; explizite Statusaktionen behalten ihre Filter. Work zeigt bei null Filtertreffern den vorhandenen Datenbestand und bietet eine Aktion zum Anzeigen aller Ergebnisse unter Beibehaltung der gewaehlten Quelle. Die verbindlichen sechs GPO-Statusoptionen bleiben erhalten. Sieben synthetische Interaktionstests sichern die historischen Versionen 1.3.3/1.4.0, den aktuellen Pruefzustand und den Auswahl-/Wiederherstellungspfad in allen vier Work-Ansichten. Explizite Nutzerentscheidung dieses Schritts: nur Anzeige korrigieren, keine reale Neuauswertung. Historische Werte werden nicht als aktuelle Pass-/Fail-Urteile oder Scores ausgegeben; keine Persistenz-, Import- oder Reimportaenderung. Phasenstatus unveraendert.
Verifikation der Anzeige-Nachkorrektur: 1.258 Tests im Gesamtlauf bestanden, acht optionale PostgreSQL-Tests uebersprungen. Ein bestehender Blattauswahl-Performance-Test ueberschritt unter Gesamtlast sein unveraendertes Fuenf-Sekunden-Limit und bestand isoliert in 2.409 ms. Alle sieben neuen Interaktionstests, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Der laufende Entwicklungsserver liefert beide korrigierten UI-Module mit HTTP 200; ihre Auslieferung wurde anhand von Code-Markern ohne Domaininhalte bestaetigt. Keine reale Neuauswertung oder Datenmutation.
GPO-Anzeige im gemischten Timeslot 2026-09-14 (Phase 7/13B, Nutzerfehlerbericht): Beim Oeffnen eines Timeslots mit Scannerlaeufen und separat assessmentgebundenen GPO-Ergebnissen bleibt der vollstaendige Assessment-Scope jetzt neben dem eingeschraenkten Scanner-Run-Scope erhalten. Data Hub und die automatische Work-Timeslotauswahl reichen beide Identitaeten weiter; der gemeinsame Workspace-Loader verwendet die ausdruecklich gewaehlten Assessments fuer Policy-Beobachtungen. Dadurch erscheinen dieselben GPO-Ergebnisse wieder in Client Info und Work, ohne fremde Scannerlaeufe einzubeziehen. Synthetische Interaktions- und Loaderregression fuer den zuvor defekten Mischpfad sowie 27 fokussierte GPO-/Navigationstests und Client-/Serverbuild erfolgreich; Gesamtlauf mit 1.252 bestandenen und acht uebersprungenen Tests sowie Local-only-/Designpruefung erfolgreich. Reine Lade-/Praesentationskorrektur ohne Import-, Persistenz-, Score-, Schema- oder Reimportaenderung; Phasenstatus unveraendert.
GPO-Baselinevarianten 2026-09-14 (Phase 7/13B, ausdruecklicher Nutzerauftrag): Evaluator 1.5.0 ersetzt die pauschale Sperre bei unterschiedlichen Sollwerten durch unabhaengige, typisierte Einzelvergleiche. Alle Varianten bestanden ergibt Pass; konfiguriertes IST gegen alle Varianten abweichend ergibt Fail; gemischte, unlesbare oder unvollstaendige Evidenz bleibt Review required. Fehlende Einstellungen ergeben nur bei belegter Satzvollstaendigkeit Not configured. Kein PingCastle erforderlich; OS-/DFL-Inventar waehlt keine GPO-Zielrolle. Widerspruechliche Werte derselben Baseline-GPO sind keine Rollenalternativen. Versionierte Originalwerte, Einzelurteile und Settingreferenzen bleiben in Ergebnis-JSON/JSONB erhalten; Work, Result DB und Export zeigen keine erfundene Baselinevereinigung. Die bestehenden fuenf GPO-Statusfilter bleiben unveraendert; Review required ist unter All sichtbar. Historische Runs 1.3.3/1.4.0 bleiben lesbar und verlangen die vorhandene explizite Data-Hub-Neuauswertung. Keine SQL-Migration, Scanner-Manifestversion, Kundendateninspektion oder reale Neuauswertung. Vertrag und Grenzen: [GPO_BASELINE_EVALUATION.md](GPO_BASELINE_EVALUATION.md). Verifikation siehe datierter Protokolleintrag; Phase 13B bleibt in Arbeit.
GPO-Work-Filterkontext 2026-09-14 (Phase 7/13B, Nutzerkorrektur und verbindliche Vorgaben): Eine eindeutige Auswahl `Source: Scan = GPOs` gilt nun auch auf dem projektweiten All-Records-Blatt als reiner GPO-Werkzeugkontext. Facetten werden vor allgemeiner Priorisierung und Zehnerlimit auf den GPO-Vertrag reduziert: Result Status, die verfuegbaren neun GPO-Fachfacetten und der dauerhaft letzte workspaceweite Source-Filter. Das Statusmenue wird aus dem festen GPO-Vertrag erzeugt und enthaelt auch dann ausschliesslich All, Deviation, Passed, Not configured, Conflicts und Duplicates, wenn kein separates GPO-Blatt geladen ist; die fuenf fachlichen Werte bleiben bei null Treffern waehlbar. GPO-Statusfilter lesen ausschliesslich das aktuelle Feld `Status`; ein `Pass` in Comparison, Historical result, Baseline, Guidance oder Evidence darf niemals einen aktuell als Not configured, Conflict, Duplicate oder Deviation klassifizierten Datensatz unter Passed aufnehmen. Scanner-, CIS-, Severity-, Category-, MITRE-, Target-, Result-message- und Reviewfacetten sowie deren Statusvokabular erscheinen nicht. Bereits gesetzte, im GPO-Kontext ungueltige Facetten werden entfernt, damit kein unsichtbarer Fremdfilter weiterwirkt; nach Aufhebung der Quelle kehrt der Mischkontext zurueck. Die synthetischen Regressionen pruefen den zuvor defekten Auswahlweg ohne separates GPO-Blatt, die Wirksamkeit eines zuvor vom Mischlimit verdraengten GPO-Filters, einen Not-configured-Datensatz mit fremdem Pass-Vergleichswert und die Rueckkehr; 51 fokussierte Filter-/Profil-/GPO-Pruefungen sowie der Gesamtlauf mit 1.243 bestandenen und acht uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Reine Praesentations- und Filterkontextkorrektur ohne Import-, Persistenz-, API-, Score- oder Reimportaenderung. Die Projektvorgaben behandeln dokumentierte Nutzerentscheidungen jetzt ausdruecklich als bindende Abnahmekriterien: spaetere Arbeiten duerfen sie nicht still abschwaechen; Konflikte mit Regeln sind vor einer Umsetzung transparent zu melden und Regelanpassungen benoetigen Nutzerzustimmung. Phasenstatus unveraendert.
GPO-Neuauswertung und OS-Inventar 2026-09-11 (Phase 7/13B/13C, Nutzerauftrag): Data Hub erhaelt einen vom Scanner-Reimport getrennten Hinweis und eine explizite Re-evaluate-Aktion fuer gespeicherte GPO-Vergleiche mit alter Engine. Die Zuordnung folgt ausschliesslich aktiven Profil-/Snapshot-/Identity-/Artefaktreferenzen; ueberholte Verarbeitungsketten erzeugen keinen falschen Hinweis. Der bestehende API-Aufruf wertet gespeicherte Eingaben aus und behaelt historische Runs. Die abgeleitete Bereitschaft ist je Repository revisionsgebunden und auf 128 Projekte begrenzt gecacht; unveraenderte Statusabrufe lesen keine Evaluationen erneut. Work zeigt alte Ergebnisse als Review required mit separatem Historical result, prominentem Versionshinweis und neutralen gespeicherten Ist-/Sollwerten. Fehlende Beschreibung und Ladefehler sind getrennt. Client Info ersetzt die kommagetrennte OS-Zeile durch Servers, Clients und Other / unclassified mit OS-Anzahlen und nur bei eindeutiger Datenbasis einer Gruppensumme. PingCastle-Name und Nb OS werden ueber exakten Lauf, Datei, Sektion, gespeicherten Quelltabellenindex und Zeile verbunden; ueberlappende Inventare werden nicht addiert, unbekannte Anzahlen bleiben ein Gedankenstrich. Originalwerte, Importprofile, Scores und Persistenzschema bleiben unveraendert; kein neuer Processing-Vertrag oder Reimport fuer diese Darstellung erforderlich. Verifiziert: 1.238 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Zusaetzlich synthetischer Import-bis-OS-Anzahl-Test und Browserpruefung bei 640/320 Pixeln ohne Inventarueberlauf sowie Statusaktionswechsel/historische Neutraldarstellung; temporaere Vorschau entfernt. Zwischenzeitliche Buildfehler im parallel bearbeiteten Timeslot-Vertrag waren beim abschliessenden Build behoben, ohne diese fremden Aenderungen zu bearbeiten. Keine Realdateninspektion, Neuauswertung oder Datenmutation; laufendes Backend nur technisch auf HTTP 200 geprueft und nicht neu gestartet. Fuer den neuen Backend-Statusvertrag ist dessen Neustart erforderlich. Phasenstatus unveraendert.
Timeslot-Vergleich P0-P2 2026-09-11 (Phase 8, Nutzerauftrag): P0 ist abgeschlossen. Der additive Vertrag `timeslot-comparison` 1.0.0 trennt Regeldefinition, scope-/objektbezogene Vergleichsidentitaet, Reportzeit, Gegenpruefung und Verpflichtung. Jedes registrierte Profil deklariert nun Scope, Zeitquelle, Ergebnisart und Negativnachweis; die aktuellen Scanner bleiben scopeweit, Generic ist nicht automatisch vergleichbar und Abwesenheit beweist nirgends automatisch Behebung. P2 ist mit einer sicheren Scannerpaarung begonnen: echte Tool-/Run-/File-Provenienz, Processingvertrag, eindeutiger Scope, neue Reportzeit und Diagnosen werden vor Fail-to-Pass geprüft; fehlende Quelle, bloße Abwesenheit und Reimportzeit bleiben ungeklärt oder nicht vergleichbar. 23 fokussierte synthetische Tests sowie Client-/Serverbuild erfolgreich. Der ausdrücklich freigegebene Mandant Dummy wurde ergänzend nur lesend geprüft und bestätigt die konservativen Grenzen; keine Datenmutation. P1 bleibt wegen Persistenz/API/Übergang offen, P2 wegen GPO/Historie/Integration offen, P3-P6 offen. Kein Processing-Manifest oder Reimport, da keine importierten Fakten geändert werden; bisherige Produktengine und UI sind noch nicht umgestellt.
Verifikation dieses Schnitts: Projektweiter Lauf mit 1.240 bestandenen Tests und acht übersprungenen optionalen PostgreSQL-Tests. Der bekannte fachfremde kombinierte Node-Importtest überschritt unter Gesamtlast einmal sein unverändertes Fünf-Sekunden-Limit und bestand isoliert mit 5/5 Tests in 3.661 ms. Local-only-, Designsprache-, Client- und Serverprüfung sind erfolgreich; kein Testlimit wurde erhöht.
Client-Info-Risikoregister 2026-09-11 (Phase 7, Nutzerauftrag): Client Info zeigt unter den bestehenden Informationsbereichen eine eigene Accepted-Risks-Kachel. Das Register dedupliziert dieselbe kanonische Finding-Vorkommnis ueber mehrere Blattprojektionen, listet links alle gespeicherten Risikentscheidungen mit Quelle, abgeleitetem Zustand und Wiedervorlage und zeigt nach Auswahl rechts den vollstaendigen aktuellen Entscheidungsinhalt sowie die unveraenderte Versionshistorie. Accepted, Scheduled, Review due, Review required, Draft, Expired und Revoked bleiben unterscheidbar; lange Inhalte werden nicht gekuerzt. Breite Ansichten verwenden die verlangte Liste-/Detailteilung, schmale Ansichten stapeln beide Bereiche. Die Darstellung liest ausschliesslich bereits geladene Reviewdaten; keine API-, Persistenz-, Scanner-, Score- oder Reimportaenderung. Zwei neue synthetische Registertests, 18 fokussierte Dashboard-/Accepted-Risk-Tests, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Gesamtlauf: 1.218 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; ein kombinierter Node-Importtest ueberschritt unter paralleler Last einmal sein unveraendertes Fuenf-Sekunden-Limit und bestand isoliert in 4.971 ms. Synthetische Browserpruefung bestaetigt Auswahlwechsel, vollstaendige Detailanzeige, Umbruch und schmale Stapelung; temporaere Vorschau entfernt. Keine Realdateninspektion oder Datenmutation; Phasenstatus unveraendert.
Mehrdatei-Reimportstatus 2026-09-11 (Phase 7, Nutzerfehlerbericht): Die Bereitschaftsanzeige bewertet gespeicherte Verarbeitungskontrakte gemeinsam je exaktem Scanlauf. Generische Begleitdateien neben genau einem erkannten Scannerprofil erzeugen bei gueltigen Vertraegen keinen falschen Pruefstatus mehr; andere Laeufe, echte Versions-/Kompatibilitaetsfehler und nicht eindeutig uebertragene Kundenreviews bleiben getrennt beruecksichtigt. Reine Pruefhinweise bieten keinen wirkungslosen erneuten Reimport an. Keine Umklassifizierung, Extraktions-/Mappingaenderung oder Datenmutation; kein neuer Processing-Vertrag und kein Reimport erforderlich. Synthetischer 22-CSV-Import samt erneutem Import und serialisierten Dateirecords sowie Schutzfaelle bestehen in 13 fokussierten Tests. Gesamtpruefung: 1.216 bestanden, acht optionale DB-Tests uebersprungen, ein fachfremder Auswahllisten-Test am Zeitlimit gescheitert; isolierte Wiederholung besteht in 1.838 ms ohne Limitaenderung. Local-only-/Designchecks und Client-/Serverbuild erfolgreich. Laufende App liefert das korrigierte Statusmodul mit HTTP 200; keine Live-Inhaltsinspektion. Phasenstatus unveraendert.
CSV-Workerstart 2026-09-11 (Phase 13, Nutzerfehlerbericht): Der isolierte Importworker initialisierte fuer jede CSV auch die vollstaendige jsdom-Abhaengigkeit innerhalb seines 30-Sekunden-Budgets. Der Node-Markupadapter laedt die fest benannte lokale Bibliothek jetzt erst beim tatsaechlichen DOM-Bedarf; CSV/JSON/Excel und begrenzte lexikalische HTML-Verarbeitung vermeiden diese Startlast. Parsergrammatik, Extraktion, Mapping und gespeicherte Fakten bleiben identisch; deshalb kein neuer Processing-Vertrag oder pauschaler Reimportbedarf. Der konkrete gemeldete Timeout wurde ohne Realdatenzugriff nicht reproduziert; die unnoetige DOM-Initialisierung wurde im gebauten Worker vor dem Fix durch einen fehlschlagenden Kostentest nachgewiesen. Nach dem Fix verarbeitet `node tests/server/import-worker-startup-check.mjs` 22 synthetische Domain-CSVs mit vollstaendigem Originalwertabgleich in beobachteten 14.170 ms ohne DOM-Laden, bei unveraenderten Zeit-/Speicherlimits. Laufendes Backend: synthetischer Parse-only-Aufruf HTTP 200 in 515 ms, Repositoryrevision unveraendert. 1.214 Tests bestanden, acht optionale DB-Tests uebersprungen; Local-only-/Designchecks und beide Builds erfolgreich. Kein Backendneustart, keine Realdatenmutation; Phasenstatus unveraendert.
Accepted-Risk-Speicherpruefung 2026-09-11 (Phase 7 mit bestehender Persistenz aus Phase 9-11, Nutzerpraezisierung): `Save draft`, `Record acceptance` und `Revoke acceptance` speichern nicht mehr unmittelbar nach der Formularvalidierung, sondern zeigen zuerst einen kompakten ASSESSOR-Pruefbericht der tatsaechlich ausgefuellten Angaben. `Change` kehrt ohne Eingabeverlust in die Maske zurueck; `OK` fuehrt erst dann die bestehende geschuetzte Speicheroperation aus. Fehler erhalten alle Eingaben, erfolgreiche Speicherung wird im Formular ausdruecklich bestaetigt. Keine Aenderung an Review-JSONB, API, Validierung, Reimportvertrag oder Datenbankschema. Verifikation: sechs fokussierte Domain-/Dialogtests, 1.214 Gesamttests, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung sowie Client-/Serverbuild erfolgreich. Phasenstatus unveraendert.
Accepted Risk 2026-09-10 (Phase 7 mit Persistenz/API aus Phase 9-11 und Sicherheit aus Phase 13, Nutzerauftrag): Button im Findingheader und strukturierter Dialog fuer Scope, Risikoszenario, Begruendung/Alternativen, initiales und verbleibendes Risiko, kompensierende Kontrollen, Verantwortliche, getrennte externe Freigabedokumentation, Nachweise, Befristung, Wiedervorlage und Ausstieg implementiert. Entwurf, Annahme und Widerruf erzeugen vollstaendige Versionen im bestehenden Review-JSONB; Same-Origin-API mit Projektpruefung, serverseitiger Validierung und atomarer Revisionspruefung. Native Befunde und Scores bleiben erhalten. Reimport erhaelt die Historie, fordert fuer einen anderen Zielbefund aber erneute Pruefung. Ablagevertrag und geplanter Ausbau in `docs/RISK_ACCEPTANCE.md`; keine SQL-Migration oder Realdatenmutation. Verifikation: 1.214 Tests bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Synthetische Browserpruefung bestaetigt Dialog, Pflichtfeldsperre und fehlerfreie Breite; temporaere Vorschau entfernt. Frontend und Backend-Health antworten HTTP 200. Keine reale Datenbank-Schreibprobe. Phasenstatus unveraendert.
Reimport-Schutz 2026-09-10 (Phase 6/8/9/13C mit Phase-7-Hinweis, ausdruecklicher Nutzerauftrag): Verarbeitungsrelevante Aenderungen an Formaterkennung, Parser/Extractor, Scannerprofil, stabiler Identitaet, Mapping, Normalisierung, Status-/Evidenzvertrag oder nativer Scoregewinnung muessen ueber append-only Processing-Manifeste versioniert und fuer bestehende Importe als Reimportbedarf sichtbar werden; reine Darstellungsaenderungen auf bereits gespeicherten Daten benoetigen keinen Reimport. Generische/tabellarische Importe ohne belastbares Profil erscheinen vorsorglich als gelber Pruefstatus. Direkter Reimport und die Uebernahme eines bereits importierten Ersatzlaufs uebertragen kundenbezogene Finding-Reviews atomar in lokaler und PostgreSQL-Persistenz. Die Zuordnung verwendet ausschliesslich die eindeutige stabile Identitaet aus Scannerwerkzeug, nativer Regel-ID und betroffenem Asset; bei fehlender oder mehrdeutiger Zuordnung wird nicht nach Titel oder Position geraten. Der vollstaendige Reviewvertrag wird kopiert und erhaelt dadurch Status, Prioritaet, Action-Plan-Zuordnung, Owner, Termin, Kommentar, Voraussetzungen, Zugriffe, naechsten Schritt, Akzeptanzevidenz, Marker/Accepted Risk sowie spaetere additive JSON-Felder. Bereits vorhandene Angaben am Ersatzlauf haben Vorrang. Nicht eindeutig uebertragbare Angaben bleiben am auditierbar erhaltenen Ursprungslauf gespeichert; Anzahl und dauerhafter `Review required`-Hinweis werden am Ersatzimport persistiert. Original-SHA, Vorgaengerbezug und Uebertragungsherkunft bleiben nachvollziehbar. Regeln und Szenarien stehen in `docs/REIMPORT_POLICY.md`. Verifikation: 1.205 Tests bestanden, acht optionale Tests uebersprungen; Local-only- und Designpruefung erfolgreich. Keine SQL-Migration, Realdateninspektion oder kanonische Datenmutation; Phasenstatus unveraendert.
Purple-Knight-Scorezuordnung 2026-09-10 (Phase 7/13C, Nutzerkorrektur): Eine native score-only Assessment Summary und die eindeutige Findings-Datei desselben Scanlaufs werden nun ueber Assessment, Werkzeug, Scanlauf und Verarbeitungskontrakt verbunden. Der native Wert bleibt der Scorebeitrag; Passed-/Deviation-Zahlen stammen aus der Findings-Datei. Die Zuordnung scheitert geschlossen bei mehreren nativen Traegern oder mehrdeutigen Ergebnispopulationen. Die synthetische Regression mit nativ 65, 96 Passed und 19 Deviations liefert in der Dashboardprojektion 65 Native score statt Pass rate 83 und behaelt 19 Deviations. 1.199 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designchecks sowie Client-/Serverbuild erfolgreich. Keine Datenmutation, Migration, Realdateninspektion oder Backendneustart; Phasenstatus unveraendert.
@ -232,6 +436,7 @@ Die heutige Tauri-Anwendung bleibt zunächst eine lokale Laufzeitumgebung. Das
| 6 | Deklarative Toolprofile und generischer Fallback | abgeschlossen |
| 7 | Automatische Filter- und Darstellungslogik | abgeschlossen |
| 8 | Vergleichsengine für Assessment-Läufe | abgeschlossen |
| 8 (Erweiterung 2026-09-11) | Belastbarer A/B-Timeslotvergleich und offene Verpflichtungen; P0 abgeschlossen, P1/P2 in Arbeit, P3-P6 offen | in Arbeit |
| 9 | Fachliche Persistenzschnittstelle | abgeschlossen |
| 10 | Lokales Backend über `localhost` | abgeschlossen |
| 11 | PostgreSQL und Datenbankmigrationen | abgeschlossen |
@ -937,6 +1142,8 @@ Abnahmekriterien:
#### Geplantes Arbeitspaket: faire Vergleiche bei fehlenden Folgequellen
Statusklarstellung 2026-09-11: Der oben dokumentierte Abschluss betrifft den historischen Vergleichskern, nicht die nachfolgende Erweiterung. P0 ist abgeschlossen, P1 in Arbeit und P2-P6 sind offen. Der verbindliche Detailplan steht in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md): getrennte Regel-/Vorkommensidentitaet, profilgebundene Gegenpruefung, Erhaltung jeder offenen Verpflichtung bei Teilabdeckung, reparierte Quell-/Versionspruefungen und expliziter A/B-Bericht. Die nachfolgenden Festlegungen vom 2026-09-06 bleiben als Ausgangspunkt erhalten; ihre pauschale Abloesung durch einen neuen Quelllauf wird durch den verpflichtungsbezogenen Nachweisvertrag praezisiert. Alte UI-Abschlussnotizen sind kein Beleg fuer die heutige Produktanbindung.
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:
@ -1402,6 +1609,8 @@ Abnahmekriterien:
### Phase 13B – GPO-Backup- und Microsoft-Baseline-Analyse
Verifizierter Zwischenstand 2026-09-18 (stabile Work-Liste und technische Pfadtitel): Die Datensatzwahl startet keine zweite kartenlokale `scrollIntoView`-Animation mehr; nur der virtuelle Listencontainer positioniert eine ausserhalb des Viewports liegende Auswahl und tut dies ohne Smooth-Animation. Die Trefferliste reserviert ihre schmale Scrollbarspur dauerhaft, sodass das Ein- und Ausblenden des Balkens die Textbreite und damit den Zeilenumbruch nicht verschiebt. Lange GPO-Registrytitel werden im Readerkopf und in der Finding-Ueberschrift ueber die gemeinsame `technical-path`-Darstellung mit Pfadschrift, vollstaendigem Inhalt und Umbruch innerhalb der Lesespalte ausgegeben; Accepted Risk und Kopfaktionen behalten ihre eigene Gridspalte. Die genaue Auswahlinteraktion und der lange Registrytitel sind synthetisch regressionsgesichert. 1.339 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Parser-, Bewertungs-, API-, SQL-, Persistenz- oder Bestandsdatenaenderung; Phase 13B bleibt in Arbeit.
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
@ -1588,6 +1797,8 @@ Abnahmekriterien:
## 8. Aktuell empfohlene Umsetzungsetappe
Ergaenzung 2026-09-11 fuer den Timeslotvergleich: Nach dem jetzt dokumentierten Planauftrag beginnt eine spaeter beauftragte Implementierung mit P0/P1 aus [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md), danach folgen P2-P6 in Abhaengigkeitsreihenfolge. Zuerst Vergleichsidentitaet und Gegenpruefung absichern, dann offene Verpflichtungen und A/B-Scorebasis, zuletzt die Oberflaeche. Dieser Dokumentationsauftrag startet keine Umsetzung und unterbricht keine unabhaengigen Phase-13B-Arbeiten. Die folgenden aelteren Empfehlungen bleiben als Projektkontext erhalten.
Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort:
1. Phase 13A ist abgeschlossen: Erkennung, technische Dokumenttypen, Source Extractors, Mappingprofile, Finding-Identität sowie allgemeine Artefakt-, Processing- und Jobgrenzen sind konsolidiert und die Scannerpersistenz verwendet den Candidate-Pfad.
@ -1607,6 +1818,41 @@ Aktualisierung nach der Katalog-Ladegrenze vom 2026-09-03: Fachlich freigegebene
## 9. Bereits getroffene Architekturentscheidungen
**Security-Domain-Mengenbalken (2026-09-20):** Die Verteilung innerhalb der sichtbaren Client-Info-Security-Domains wird als dezenter Balken in einer eigenen Subgrid-Spalte dargestellt. Das gemeinsame Raster bemisst links den laengsten Domainnamen und rechts die breiteste Mengenzahl; der Balken bleibt mit gemeinsamem Abstand vollstaendig dazwischen, endet bei allen Zeilen an derselben Kante und waechst nach links. Alle Balken erben exakt den gemeinsam am Posture-Bereich gesetzten Tone des Overall Scores; dadurch stimmen sie immer mit der aktuellen Donutfarbe ueberein, waehrend die einzelnen Domainknoten weiterhin ihre eigene Pass-Rate-Farbe besitzen. Bezugsmenge ist die Summe exakt der angezeigten Domainmengen; eine Zeile verwendet denselben Wert, den ihre rechte Zahl bereits zeigt, einschliesslich des bestehenden Review-only-Falls. Der Balken ergaenzt die Zahl nur visuell, ist `aria-hidden` und darf weder fachliche Mengen noch Bewertung, Klassifikation oder Navigation beeinflussen.
- 2026-09-20, Mindestgroessen fuer Information und Lesetext: Primaere Information in Tabellen, Kacheln und Feldbeschriftungen darf nicht unter 14 px fallen; zu lesende Werte und fortlaufender Text verwenden 16 px. Kleinere Rollen sind ausschliesslich fuer kompakte Controls, technische Metadaten und Badges zulaessig und duerfen nicht zum Verdichten primaerer Information missbraucht werden. Bestehende semantische 14-/16-px-Tokens bleiben die Quelle; keine lokale Literal- oder View-Sondergroesse.
- 2026-09-20, PingCastle-OS-Zustandsprojektion: `Nb OS`, `Nb Active`, `Nb Inactive`, `Nb Enabled` und `Nb Disabled` sind voneinander unabhaengige, native Werte derselben retained source row. Die UI darf sie gemeinsam anzeigen, aber weder Differenzen berechnen noch Werte ueber Quellzeilen oder Inventare hinweg zusammenfuehren. Die vorhandene generische Umgebungsextraktion bleibt die Quelle; keine neue Parser- oder Persistenzsemantik ist erforderlich.
- 2026-09-20, gemeinsamer Deviation-Scope: Data Hub und Client Info verwenden fuer Ergebnisanzahl und Prozentnenner dasselbe gemischte, timeslotgebundene Projektblatt sowie dieselbe zentrale Zustandsklassifikation. Scanner-Run-IDs allein sind keine vollstaendige Ergebnisgrenze, weil aktuelle GPO-/Policy-Ergebnisse assessmentgebunden vorliegen. Der Data-Hub-Donut bleibt eine Statusprojektion und macht ein GPO-Backup nicht zu einem Scanner-Run.
- 2026-09-18, Client-Info-Praesentation: Die freigegebene Ableton-Designsprache gilt auch fuer Client Info. Der Nutzer verlangt ausdruecklich den Erhalt der gemessenen gebogenen Overall-zu-Domaenen-Verbindungen. Zusammenfassung vor aufklappbaren Evidenz-/Governancedetails; keine Aenderung der fachlichen Daten- und Scorevertraege. Der exakte Resize-/Disclosure-/Risikowechselpfad ist synthetisch abgesichert.
- 2026-09-20, Client-Info-Breiten- und Zeilenvertrag: Posture und Aktionsrail verwenden auf breiten Ansichten exakt 1:1 und teilen die verfuegbare Zeilenbreite zur Haelfte. Diese spaetere Nutzerkorrektur ersetzt die kurzzeitig dokumentierte 2:1-Aufteilung. Jede gemeinsame Kachelzeile behaelt identische Hoehe und Unterkante. Accepted Risks uebernimmt die feste linke Client-/Timeslot-Auswahlbreite `clamp(420px, 27vw, 520px)` und die rechte Informationsflaeche; unterhalb des bestehenden schmalen Breakpoints bleibt die Stapelung erhalten.
- 2026-09-20, Overall-Score-Inhaltsvertrag: Unter dem Score-Donut erscheinen weder eine textuelle Statuswiederholung noch ein zweiter Critical-Findings-Zaehler. Assessment Coverage bleibt auf die kompakte Inhaltsbreite begrenzt und darf die gebogenen Domaenenlinien nicht beruehren oder ueberlagern.
**Verbindliche Ableton-Designreferenzen einschliesslich Client Info (2026-09-18):** Die ausdrueckliche Nutzerkorrektur ersetzt die bisherige Client-Info-Sonderpalette/-Geometrie durch denselben visuellen Vertrag wie Data Hub, Work und Pathfinder. Die drei bestehenden, freigegebenen Ansichten bleiben Referenzen; sie werden nicht an neue Entwuerfe angepasst. Aktuelle Projekt-Tokens und AGENTS.md haben Vorrang vor historischen Skill-Assets, Archetypen und verworfenen Bildentwuerfen. Der Vertrag gilt bereits fuer Designvorschlaege: gemeinsame neutrale Flaechen, transparente Segmente, flache eckige Tiles, vorhandene Controls, Inter, gelbe/bernsteinfarbene Auswahl und Aktionen, blauer Fokus, semantische Statusfarben, 20px Inset und volle 360-Grad-Donuts. Fachliche Client-Info-Hierarchie, Scoremodelle und Quelle-/Work-Drilldowns bleiben erhalten. Aeltere abweichende Entscheidungen im Verlauf sind historische Eintraege und durch diese Korrektur nur hinsichtlich der visuellen Ausnahme supersediert.
**Vollstaendiger Security-Domain-Katalog im Work-Filter (2026-09-18):** Der unveraenderte gemeinsame Client-Info-Katalog ist der Optionsvertrag, nicht die aktuell gefilterte Treffermenge. Alle acht Domains bleiben bei null Treffern auswaehlbar; Unclassified wird nur bei vorhandenen unklassifizierten Datensaetzen ergaenzt. Eigene Auswahl bleibt bei der Facettenzaehlung ausgenommen; andere aktive Filter gelten weiter. Noch gueltige aktive Kategorie-/Statusauswahlen verschwinden bei null Treffern nicht automatisch. Der normale Domain-Deviation-Drilldown aus Client Info verwendet denselben sichtbaren Filter statt pathFindingIds; ausdrueckliche Review-only- und Einzelbefundpfade bleiben separat begrenzt. Quellen-/Timeslotgrenzen und Domainzuordnung werden nicht erweitert.
**Aktuelle GPO-Laufauswahl und autoritative Statusfilter (2026-09-18, beauftragter Fehlerfix):** Work und PostgreSQL-Result-DB messen Laufabdeckung an verschiedenen Baseline-IDs in den erhaltenen Ergebnissen; mehrere Analyseversionen eines Pakets sind keine zusaetzliche Abdeckung. Fehlen solche IDs vollstaendig, bleiben deklarierte Analyseverweise der Fallback. Bei gleicher Abdeckung gewinnt der neuere Lauf, bei gleichem Zeitpunkt die ID. Historie bleibt unveraendert. Deviation darf ein autoritatives Fail nicht aufgrund des angezeigten Configured Value verwerfen; die vereinbarten Sonderfaelle fuer erforderliche Schluessel-/Leerstringpraesenz bleiben Fail, das gewoehnliche Not-configured-Urteil bleibt neutral. Result DB zaehlt weiterhin Vergleiche, Work Einstellungen. Synthetische Interaktionstests sichern beide GPO-Work-Einstiege sowie die Result-DB-Auswahl; optionale PostgreSQL-Paritaetstests verwenden ausschliesslich synthetische CTEs in einer read-only-Verbindung ohne Schema-/Tabellenanlage.
**Result-DB-Statusfilterkorrektur (2026-09-18, ausdruecklicher Nutzerauftrag):** Die fruehere Ausnahme vom 2026-09-07, GPO-Klassifikationen unabhaengig von Deviation zu zaehlen und Deviation bei Auswahl zu entfernen, ist abgeloest. Result Status beachtet wie andere Facetten jeden fremden aktiven Filter und nimmt nur die eigene Auswahl aus seiner Alternativzaehlung aus. Ausgewaehlte Nulltreffer bleiben sichtbar. Gemeinsame Labels mit Work sind reine Praesentation; Result DB behaelt Einzelvergleiche, Work seinen technischen Setting-Rollup. Review required bleibt fachlich eigenstaendig; No baseline rule und Not comparable werden nicht durch Freigabe interner Beobachtungen fuer Result DB erfunden.
**Work rollt Baselinepakete auf die technische Einstellung auf (2026-09-18):** Persistierte Paketvergleiche bleiben eigenstaendige Evidenz; Work zeigt je Projekt/Aggregation/Scope/Identitaet genau eine Zeile mit dem vorhandenen Zaehlerurteil. Baselineauswahl filtert die Mitgliedschaft, nicht das Urteil. Abweichende Sollwerte bleiben paketbezogen; Quellenmarkierungen benoetigen Evidenz gegen alle aktuellen Varianten. Alte lokale Review-Identitaeten werden fuer lesbaren Erhalt mitgefuehrt; widerspruechliche Reviews und Risikoakzeptanzen werden nicht automatisch vereinigt. Details siehe GPO_BASELINE_EVALUATION.md.
**Work-Listenstabilitaet und technische Pfade (2026-09-18):** Eine Auswahl darf nicht gleichzeitig vom virtuellen Listencontainer und vom einzelnen Listenelement animiert werden. Der Container ist die einzige Scrollautoritaet und positioniert eine notwendige Auswahl deterministisch ohne Smooth-Animation; sichtbare Karten loesen keinen eigenen Scroll aus. Scrollbare Datensatzlisten reservieren ihre Scrollbarspur. Vollstaendige Registrypfade in Titeln verwenden dieselbe inerte `technical-path`-Darstellung wie Pfadfelder, bleiben vollstaendig lesbar und duerfen Kopfaktionen nicht ueberlagern. Der Titelvertrag umfasst `registry · <Pfad>` sowie `Registry Values · MACHINE\\...`/`USER\\...`; nackte `MACHINE\\`-/`USER\\`-Texte ausserhalb dieses Vertrags werden nicht umklassifiziert. Diese Darstellungsentscheidung aendert weder Originaltitel noch Datensatzidentitaet.
Festlegung 2026-09-17 (Phase 13B, GPO-Quellwiederherstellung): Die persistierte backend-controlled-Referenz bleibt als Provenienz erhalten; die aktuelle Dateiverfuegbarkeit wird separat und ohne Repository-Revisionscache gelesen. Nur ENOENT bedeutet fehlende Bytes und Upload required; Zugriffs-, Konfigurations- oder ungueltige Objektpfade ergeben Storage unavailable. Automatische Reparatur, Retry und Reprocess duerfen ohne verfuegbare Quelle keinen Job erzeugen; verschwindet sie nach dem Einreihen, endet der erste fehlgeschlagene Versuch ohne automatische Wiederholung. Der gezielte Originalupload bindet an Projekt/Artefakt und gegebenenfalls Timeslot, prueft Groesse und SHA-256 und stellt die urspruengliche Speicher-ID atomar ohne Ueberschreiben wieder her. Gespeicherte Analysen bleiben erhalten. Reine Neuauswertung erhaltener Eingaben bleibt moeglich; veraltete Analyzer benoetigen weiterhin zuerst Reprocessing. Kein Analyzerbump, keine neue Interpretation und keine Bestandsmutation allein durch die Verfuegbarkeitsanzeige.
Festlegung 2026-09-16 (Phase 7/13B, Policy-Rollup-Praezisierung): Diese Entscheidung ersetzt die am selben Tag dokumentierte Interpretation, nach der Conflict-/Duplicate-Container trotz eines belegten Mitglieds-Fails score-neutral bleiben koennten. Verdict und Diagnose sind getrennte Achsen. Ein belegter Fail eines konfigurierten, vergleichbaren In-Scope-Mitglieds macht die technische Policy-Einstellung insgesamt genau einmal zu Fail; Conflict/Duplicate bleibt als zusaetzliches Merkmal. Ohne belegten Fail ergeben ausschliesslich Pass-Mitglieder einen Policy-Pass, waehrend ungeklaerte Anwendbarkeit oder Prioritaet Review required bleibt. Ein konkretes Geraet wird ohne Effective-State-/RSoP-Evidenz weiterhin nicht als verwundbar behauptet.
Festlegung 2026-09-16 (Phase 7/13B, GPO-Bewertungsscope): Die operative GPO-Analyse ist misconfiguration-first. Scorefaehig sind nur konfigurierte, technisch eindeutig vergleichbare Kundeneinstellungen im anwendbaren Microsoft-Baseline-Scope. `Not configured`, `Duplicates` und `Conflicts` bleiben sichtbare, eigenstaendige und score-neutrale Containerkategorien. Ein unabhaengig belegter Fail eines darin enthaltenen konfigurierten Werts bleibt genau einmal scorewirksam; weder Containerstatus noch Quellanzahl erzeugen zusaetzliches Gewicht. Vollstaendige Baseline-Adoption ist ein eigener Hardening-Auftrag und darf aus dem normalen GPO-Score nicht behauptet werden. Diese Nutzerentscheidung ersetzt jede Lesart, nach der fehlende Microsoft-Empfehlungen ueber Coverage oder einen impliziten Nenner den operativen Score mindern.
Festlegung 2026-09-16 (Phase 7, gemischter Work-Filterkontext): GPO-spezifische Facetten gehoeren ausschliesslich zum eindeutigen Werkzeugkontext `group-policy`, der durch das GPO-Blatt oder eine eindeutige GPO-Quellenauswahl entsteht. `Source: Scan = All` ist ein gemischter Kontext; dort bleiben Result Status, allgemeine Scannerfacetten und der permanente Source-Filter sichtbar, waehrend GPO-exklusive Felder nicht am allgemeinen Facettenlimit teilnehmen. Jeder Kontextwechsel entfernt gespeicherte Facettenauswahlen, die im neuen sichtbaren Vertrag nicht mehr gueltig sind.
Festlegung 2026-09-16 (Phase 7, Work-Snapshots): Die vollstaendig wirksame Work-Projektion ist der Snapshotvertrag. Ein gespeicherter Ansichtsstand umfasst deshalb auch den Finding-Scope eines Drilldowns und darf ihn beim Wiederherstellen nicht pauschal leeren. Der Scope wird als Datensatz-ID-Liste lokal und timeslotgebunden gespeichert; alte Eintraege ohne Feld werden abwaertskompatibel als uneingeschraenkte Ansicht gelesen. Pathfinder-Snapshots behalten zusaetzlich ihre Graphknoten und wenden daraus weiterhin ihren exakten Finding-Scope an.
Festlegung 2026-09-14 (GPO-Varianten, Nutzerfreigabe): Unbekannte Zielrolle verhindert kein eindeutiges Urteil ueber alle beruecksichtigten Baselinevarianten. Die Vereinigung von Kontenlisten ist kein neuer Sollwert; jede Variante behaelt ihr eigenes Einzelurteil. Dies ersetzt fuer neue Evaluationen die pauschale Review-/Konfliktsperre bei abweichenden Baselinewerten aus dem GPO-Vorlagenkontext vom 2026-09-09, nicht die historische Evidenz. Explizite Rollenwahl, Originalherkunft, fehlende Wirksamkeitsevidenz und strikte Trennung von Baselinealternativen und Kunden-GPO-Konflikten bleiben bestehen. Bewertungswechsel verlangt Evaluator 1.5.0 und explizite Neuauswertung, nicht automatisch einen Scanner-Reimport.
Festlegung 2026-09-11 (Phase-8-Planergaenzung): Regeldefinition, objekt-/scopebezogene Vergleichsidentitaet und zeitgebundene Beobachtung bleiben getrennt. Abwesenheit ohne belastbare Gegenpruefung ist keine Behebung. Scorevergleich und Behebungsfreigabe erhalten unterschiedliche fachliche Bedingungen; teilweise neue Evidenz ersetzt nicht pauschal alle alten Verpflichtungen. A und B bleiben die ausdrueckliche Messbasis, aeltere Historie dient getrennt der Verpflichtungs-/Lifecycleverfolgung. Die Festlegung praezisiert das Quellenmodell vom 2026-09-06 und ersetzt fuer die kuenftige Implementierung die historische Regel `abwesend = resolved`; keine historischen Daten oder Ergebnisse werden umgeschrieben. Details und Grenzen: [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md).
Festlegung 2026-09-10 (Purple-Knight-Begleitdateien): Ein nativer Score darf aus einer separaten Summary auf die Findings desselben Scanlaufs uebertragen werden, wenn genau ein nativer Traeger und eine eindeutige Ergebnispopulation denselben Werkzeug- und Verarbeitungskontrakt teilen. Datei- und Vorkommensherkunft bleiben gemeinsam erhalten; Mehrdeutigkeit verhindert die Verbindung.
Festlegung 2026-09-10 (weitere Scanner): Native Gesamtwerte werden bei jeder Profilerweiterung zuerst geprueft und deklarativ mit ihrer Bedeutung erfasst. Die Scoredefinition ist Pflicht, die Berechnung gemeinsam; ihre Version gehoert zur Vergleichskompatibilitaet. Extraktion und Originalherkunft bleiben im generischen Importpfad. AGENTS.md und docs/SCORING_MODEL.md halten die Erweiterungsregel fest.
@ -1644,6 +1890,7 @@ Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene
| Datum | Entscheidung | Begründung |
| 2026-09-07 | Die geladene Client Info ist innerhalb der Wurzelklasse `.client-command-center` eine feste dunkle Command-Center-Ausnahme von der allgemeinen Kachelkomposition. Die genehmigte Hierarchie ist Client/Assessment/Source, sechs Icon-Fakten, Overall Score mit acht visuell verbundenen Security Domains, Priority Findings plus Assessment Gaps und abschliessend fuenf werkzeugspezifische Source-Flaechen. | Die ausdruecklich genehmigte Referenz ist ein zusammenhaengendes Erstgespraechs-Dashboard und keine Sammlung allgemeiner Anwendungskacheln. Eine eng begrenzte Ausnahme bildet diese Hierarchie originalgetreu ab, ohne Data Hub, Work, Result DB, Pathfinder oder Shell visuell zu veraendern. |
| 2026-09-07 | Die Nutzerpraezisierung erweitert die unmittelbar folgende Positionsentscheidung: `Source: Scan` ist eine workspaceweite Navigationsfacette mit stabiler ID und vollstaendiger Optionsliste aus dem projektweiten All-Records-Blatt. Eine Auswahl aus einem dedizierten GPO-/Fachblatt wechselt auf dieses Aggregat und aktiviert dort direkt den ausgewaehlten Scan; lokale Facettennachzaehlung darf die Liste nicht reduzieren. | Der Filter soll nicht nur die aktuell sichtbaren Records einschraenken, sondern jederzeit den direkten Wechsel zu jedem geoeffneten Scan ermoeglichen. Nur die projektweite Quelle und ein positionsunabhaengiger Identifikator verhindern, dass ein Fachblatt alle anderen Scanner aus der Auswahl verliert oder der gesetzte Filter beim Blattwechsel unbrauchbar wird. |
| 2026-09-14 | Eine eindeutige `Source: Scan`-Auswahl ist auf dem projektweiten All-Records-Blatt ein bindender Werkzeugkontext. Fuer GPOs gelten dort derselbe Facetten- und Statusvertrag wie auf dem dedizierten GPO-Blatt; die Kontextreduktion geschieht vor allgemeiner Priorisierung und Facettenlimit, ungueltige Altfilter werden entfernt. Dokumentierte Nutzerentscheidungen sind Abnahmekriterien; ein Konflikt mit Projektregeln ist vor der Umsetzung zu melden und darf nicht still umgangen werden. | Eine reine Datensatzfilterung bei unveraendertem Mischprofil zeigt fachfremde Scannerfilter und kann unsichtbare Fremdfilter aktiv lassen. Der Kontext muss deshalb die angebotene und wirksame Filtermenge bestimmen. Transparente Konfliktmeldung erhaelt die Nachvollziehbarkeit bindender Vereinbarungen. |
| 2026-09-07 | `Source: Scan` ist ein permanenter Provenienzfilter jeder Work-/Graph-Ansicht und steht stets als letzter Filter ganz rechts. In der reinen GPO-Ansicht ruecken die fachlichen Filter linksbuendig zusammen; deren sichtbares Limit betraegt elf statt zehn, damit Status, neun GPO-Fachfacetten und die Source-Facette gleichzeitig erhalten bleiben. Ein fehlendes explizites Source-Feld wird praesentativ aus Scan-/Profilmetadaten ergaenzt. | Die Quellenherkunft muss unabhaengig vom aktiven Werkzeug jederzeit auffindbar und an einer stabilen Position bedienbar sein. Einwertige Source-Werte sind fuer Provenienz weiterhin relevant und duerfen weder durch die allgemeine Nuetzlichkeitspruefung noch durch das Facettenlimit verschwinden. |
| 2026-09-07 | Kunden sichtbare GPO-Quellen zeigen ausschliesslich die interne Referenz ab GPO-GUID beziehungsweise `DomainSysvol/GPO`; jeder lokale oder artefaktbezogene Praefix wird in der Praesentationsschicht entfernt. Quellen stehen unmittelbar als technische Pfadzeilen in der Evidence-Zelle, ohne aufklappbaren `Show source`-Zwischenschritt und ohne doppelte Textueberschrift. Nicht eindeutig sanitisierbare Pfade werden nicht teilweise angezeigt. | Lokale Ablage- und Backupordner sind fuer die fachliche Kundenbewertung irrelevant und koennen interne Namen offenlegen. Die GPO-GUID plus interner Policy-/Datei-Locator reicht fuer eindeutige, kundenfaehige Evidenz; fail-closed Reduktion verhindert versehentliche Preisgabe bei unbekannten Pfadformen. |
| 2026-09-07 | GPO-Duplicates folgen derselben vierstufigen Results-Lesepipeline wie Conflicts, verwenden aber `Observed value` mit genau einer gemeinsamen Wertzeile und einen informativen Duplicate-Hinweis. Dieser Hinweis behauptet weder Fehler noch Unbedenklichkeit, sondern ausschliesslich: identische Quellenbelegung und fuer sich allein keine Baselineabweichung. | Gleiche Informationsarchitektur macht Conflict und Duplicate vorhersehbar bedienbar. Die getrennte Semantik verhindert zugleich, dass reine Quellenredundanz als Fehlkonfiguration erscheint oder eine gegebenenfalls separate Baselineauswertung verdeckt. |
@ -1923,6 +2170,10 @@ Abnahme 2026-08-23: 677 Standardtests bestanden, 8 optionale PostgreSQL-Integrat
## 10. Offene Entscheidungen
Timeslotvergleich 2026-09-11: P0/P1 muessen pro Scanner/Format/GPO die belastbaren Objekt-/Scopefelder, Vergleichsgranularitaet und Negativnachweisfaehigkeit sowie die konkrete Reimportbetroffenheit bestimmen. Speicher-/API-Vertrag und Uebergang fuer offene Verpflichtungen und auditierte Scopeentscheidungen sind vor Migration festzulegen. Native Teilpopulationen duerfen nur mit separat belegten Beitraegen verglichen werden; ein zusaetzlicher risikogewichteter Expositionsindex ist nicht freigegeben. Siehe Detailentscheidungen in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md).
Accepted-Risk-Entscheidung 2026-09-10: Der Nutzer hat keine bestehenden Freigaberegeln und beauftragt einen Best-Practice-Ausgangspunkt. Getrennter Risk Owner/Genehmiger, belegte externe Zustimmung, Befristung und Wiedervorlage sind Pflicht fuer eine Annahme. Es wird keine unbelegte allgemeingueltige Hoechstlaufzeit vorgegeben. Die lokale Ablage erfolgt additiv in `assessor_reviews.data.riskAcceptance` mit append-only Versionssnapshots. Fuer Phase 14 sind SSO-gebundene Akteure, kundengebundene Rollen, normalisierte Entscheidungs-/Versions-/Freigabe-/Evidenztabellen, manipulationsnachweisbare Aufbewahrung und serverseitige Fristenueberwachung vorgesehen; Umsetzungskriterien stehen in `docs/RISK_ACCEPTANCE.md`. Keine organisatorische Freigabe oder rechtliche Wirksamkeit wird aus einem ausgefuellten Formular abgeleitet.
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.
@ -1944,6 +2195,42 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 11. Risiken
| Ein flaechenfuellender Mengenbalken kann in Domainnamen hineinwachsen, hinter unterschiedlich breiten Zahlen enden oder durch eine vom Overall Score abweichende Farbe eine zweite Bedeutung suggerieren. Eine Maximalwert-Normalisierung wuerde zudem keine Anteile an der Gesamtverteilung zeigen. | Ein gemeinsames Vier-Spalten-Subgrid fuer Score, laengsten Namen, Balken und breiteste Zahl verwenden. Balken bleiben mit gemeinsamem Abstand ausschliesslich in ihrer dritten Spalte, erben den am Posture-Bereich gesetzten Overall-Tone und werden gegen die Summe der angezeigten Domainmengen skaliert. Exakte Zahlen bleiben rechtsbuendig sichtbar und autoritativ; `aria-hidden`, synthetische 75/25-Regression sowie Light-/Dark-, Donutfarb-, Kanten- und Ueberlaufpruefung sichern die Grenze. |
- 2026-09-20, Lesetypografie: Groessere 14-/16-px-Rollen koennen bei langen Werten mehr Zeilen und vertikalen Scrollbedarf erzeugen. Inhalte duerfen deshalb umbrechen oder in den bereits dafuer vorgesehenen Kacheln scrollen, aber niemals gekuerzt, ellipsiert oder wieder auf kleinere Schrift reduziert werden. Kompakte Metadatenrollen bleiben nur fuer tatsaechlich sekundaere technische Angaben zulaessig; ihre Verwendung fuer Primaerinformation waere eine Regression.
- 2026-09-20, PingCastle-OS-Zustaende: Active/Inactive und Enabled/Disabled sind getrennte Quellachsen und muessen nicht dieselbe Verteilung ausdruecken. ASSESSOR zeigt deshalb alle vier Werte unveraendert und behauptet weder eine Partition noch eine Konsistenzbeziehung zu `Devices`. Mehrere PingCastle-Berichte koennen dieselben Systeme ueberlappend inventarisieren; sie bleiben getrennte `Inventory n`-Zeilen ohne Summierung. Schmalere oder umfangreichere Tabellen duerfen innerhalb der bereits scrollbaren Kachel scrollen, aber keine Spalte abschneiden oder Werte aus Platzgruenden entfernen.
- 2026-09-20, gemischte Data-Hub-Metrikprojektion: Das vollstaendige Projektblatt kann bei grossen Timeslots merklich spaeter als die Mandantenliste bereitstehen, weil aktuelle Policy-Evaluationen und ihre autoritative Rollup-Projektion einbezogen werden. Die Statuszeile bleibt deshalb waehrend des Ladens `aria-busy`, verwirft verspätete Antworten nach einem weiteren Auswahlwechsel und zeigt niemals die vorherige Mandantenkennzahl weiter. Eine kuenftige Beschleunigung darf nur cachen oder denselben serverseitigen Projektionsvertrag bereitstellen; ein Rueckfall auf Scanner-only-Zaehlen waere fachlich falsch. Die aktuelle Live-Abnahme belegt Dummy und LSW, nicht jede denkbare Datenmenge.
- 2026-09-18, Client-Info-Designangleichung: synthetisch visuell und interaktiv in beiden Themes verifiziert, nicht als Abnahme jedes realen Datenumfangs auszugeben. Sehr lange Quellwerte muessen weiterhin umbrechen; aufklappbare Inhalte duerfen nicht zu Verlust oder Umdeutung von Evidenz fuehren. Der bestehende schmale Stapelmodus zeichnet keine waagerechten Kurven. Keine produktiven Daten oder laufenden App-/Backendprozesse wurden fuer diese Abnahme angefasst.
- 2026-09-20, Client-Info-Breitenkorrektur: Die breite Dummy-Ansicht und der gestapelte 700-px-Fallback sind verifiziert. Gleiche Zeilenhoehen koennen innerhalb der kuerzeren Kachel bewusst freie Flaeche erzeugen; ein Hoehenversatz ist jedoch ausdruecklich verboten und darf nicht erneut als Deadspace-Loesung verwendet werden. Sehr lange Accepted-Risk-Titel oder eine deutlich groessere Risikoliste koennen die linke Rail weiterhin vertikal verlaengern; Inhalte duerfen deshalb weder gekuerzt noch durch eine kleinere Schrift in die feste Breite gezwungen werden. Die Aenderung ist reine Praesentation und nimmt keine Score-, Risiko- oder Persistenzmutation vor.
Designkonsistenz (2026-09-18): Historische assessor-ui-Assets und Archetyp-/Abnahmeskizzen enthalten ueberholte Farben, Schriftgroessen und Layoutvorschlaege. Ihre ausdrueckliche Unterordnung unter die freigegebenen Referenzen sowie das Entfernen der Kopieranleitung verhindert, dass sie erneut als aktuelle Implementierungsbasis behandelt werden. Die Verankerung allein korrigiert noch keine bestehende Client-Info-Darstellung. Deren spaetere Umsetzung benoetigt einen direkten Referenzabgleich bei normalem Zoom und eine Light-/Dark-Abnahme mit synthetischen Daten; generierte Mockups sind kein Nachweis fuer richtige Kennzahlen, Runtime-Verhalten oder bereits implementiertes Design.
Security-Domain-Filter (2026-09-18): Die vollstaendige Auswahlliste behauptet keine vorhandenen Befunde in jeder Domain. Eine Domain mit null Treffern ergibt bewusst eine leere Ansicht; bestehende Quellen-, Status-, Such- und sonstige Filter muessen bei Bedarf vom Nutzer entfernt werden. Reine GPO-Datensaetze bleiben ausschliesslich GPO & Baseline zugeordnet. Review-only- und Einzelbefundpfade behalten ihre explizite Datensatzeinschraenkung; diese sind nicht mit dem jetzt frei wechselbaren Domain-Deviation-Drilldown gleichzusetzen.
GPO-Zaehlerabgleich (2026-09-18): Unterschiedliche Zaehlobjekte bleiben beabsichtigt; bei unveraendertem Dummy entsprechen 127 Vergleiche genau 25 Einstellungen. Bereits geladene Ansichten koennen bis zum erneuten Laden ihren vorherigen Ergebnisbestand halten. Laufabdeckung bleibt eine Darstellungsauswahl und kein Nachweis von Baseline-Anwendbarkeit oder Endgeraetewirksamkeit. Die Gesamtsuite enthaelt eine fachfremde bestehende XML-Testannahme, die code-Tags im gesamten Reader verbietet und am vorhandenen Policy-Analyzer-Suchschluessel anschlaegt; dieser Fix veraendert weder den Reader noch diesen Test.
Result-DB-Statusfilter (2026-09-18): Widerspruechliche explizite Kombinationen wie Passed mit Deviation Yes liefern bewusst null Treffer; beide Auswahlen bleiben sichtbar und koennen entfernt werden. Die Statusfacette zaehlt erreichbare Alternativen unter allen anderen Filtern, daher sind ihre Werte bei aktiver eigener Mehrfachauswahl keine additive Aufteilung der angezeigten Treffer. Result DB und Work behalten unterschiedliche Zaehlobjekte. Die synthetische Abnahme benoetigt keine Kundendaten.
- Work-Baseline-Rollup (2026-09-18): Unterschiedliche fruehere lokale Bearbeitungsnotizen benoetigen eine bewusste Entscheidung fuer die gemeinsame Zeile; sie bleiben im Reader lesbar. Risikoakzeptanzen bleiben an ihrer Originalidentitaet. Der Fix ist ausschliesslich synthetisch geprueft; auf ausdruecklichen Nutzerwunsch wurden keine Kundendaten zur Laufzeit geoeffnet. Result DB behaelt absichtlich die einzelnen Paketvergleiche als Evidenz.
Work-Listen- und Pfadtitel-Restgrenze 2026-09-18: Die Liste nutzt weiterhin eine geschaetzte Zeilenhoehe fuer die begrenzte Virtualisierung, waehrend sehr lange Titel hoeher umbrechen koennen. Der Overscan haelt die sichtbaren Knoten bereit; die beseitigten konkurrierenden Animationen und die reservierte Scrollbarspur verhindern den konkret beobachteten Auswahlversatz, sind aber noch kein gemessener variabel hoher Virtualizer. Bei kuenftig nachweisbarem Versatz waehrend rein manuellen Scrollens ist eine gemessene Hoehenkarte mit Ankerkorrektur erforderlich, ohne die verbindliche Begrenzung montierter Zeilen aufzugeben. Technische Pfade duerfen an beliebigen Zeichen umbrechen, werden jedoch nicht gekuerzt oder inhaltlich normalisiert. Der konkrete `Registry Values · MACHINE\\...`-Fall ist synthetisch im gerenderten Reader abgesichert; eine zusaetzliche Windows-UI-Sichtpruefung war in dieser Sitzung nicht verfuegbar.
GPO-Quellverfuegbarkeit 2026-09-17: Der am selben Tag dokumentierte Drei-Versuche-/Dead-Letter-Nebenbefund ist fuer GPO-Quelldateien durch Livepruefung, Enqueue-Sperren und pruefsummengebundene Wiederherstellung adressiert. Die Statuspruefung belegt Erreichbarkeit, nicht Inhaltsintegritaet; die vollstaendige Hashpruefung bleibt am Lesen und Wiederherstellen. Ein Ordnerupload muss dasselbe unveraenderte Transportpaket reproduzieren; abweichende Bytes werden bewusst abgewiesen. Der Speicher kann zwischen Pruefung und Lesen verschwinden; deshalb bleibt der erste fehlgeschlagene Job als technischer Verlauf gespeichert, die UI zeigt Upload required. Zugriffsfehler werden nicht als Datenverlust bezeichnet. Keine automatische Suche in alternativen Speicherorten und kein Wechsel des konfigurierten Stores; PostgreSQL-Ausfuehrung hier synthetisch auf Transaktionsebene geprueft, kein Live-Datenbanktest oder Backendneustart.
GPO-Policy-Rollup 2026-09-16: Ein konservatives Policy-Fail darf nicht mit der Behauptung verwechselt werden, ein bestimmter Server oder Client sei effektiv verwundbar; GPO-Backups belegen ohne RSoP/Effective State keine konkrete Anwendung. Umgekehrt darf ein Conflict-/Duplicate-Label keinen belegten Mitglieds-Fail verdecken. Die kuenftige Umsetzung muss Verdict und Diagnoseflags getrennt persistieren/projizieren, je technischer Policy-Identitaet exakt einmal zaehlen und mehrdeutige Anwendbarkeit als Review required halten. Bis diese Kriterien versioniert und synthetisch verifiziert sind, gilt die neue Regel als bindender Vertrag, nicht als abgenommene Laufzeitfunktion.
GPO-Misconfiguration-Scope 2026-09-16: Der score-neutrale Ausschluss nicht konfigurierter Empfehlungen verhindert eine unbeherrschbare Baseline-Rolloutliste, bedeutet aber bewusst, dass der GPO-Score keine Aussage ueber vollstaendige Microsoft-Baseline-Abdeckung trifft. Diese Grenze muss in jeder Scoreanzeige und jedem Export sichtbar bleiben. Die Einzel-Fail-Ausnahme in Conflict-/Duplicate-Containern darf erst implementiert werden, wenn technische Identitaet und Ergebnisbeitrag dedupliziert sind; sonst koennten gleiche Quellwerte mehrfach zaehlen oder mehrdeutige Konflikte faelschlich als Fail erscheinen. Historische Evaluationen duerfen nach einer kuenftigen semantischen Umsetzung nicht ohne neue Vertragsversion und explizite Neuauswertung umgedeutet werden.
Gemischter Work-Filterkontext 2026-09-16: GPO-spezifische Felder bleiben in den unveraenderten Datensaetzen des All-Records-Blatts vorhanden, werden bei Source All aber bewusst nicht als Facetten angeboten. Sie sind nach eindeutiger GPO-Auswahl vollstaendig verfuegbar. Neue GPO-Facetten muessen deshalb sowohl in den GPO-Vertrag als auch in die Liste der kontextgebundenen Facetten aufgenommen werden; ein fehlender Eintrag koennte sie im Mischkontext erneut sichtbar machen oder im GPO-Kontext ausblenden. Permanente Wechselregressionen begrenzen dieses Risiko.
Work-Snapshots 2026-09-16: Gespeicherte Finding-IDs sind an den exakten Work-/Timeslot-Scope gebunden. Werden Ergebnisse in einem spaeter geladenen Verarbeitungsstand ersetzt, kann ein alter lokaler Snapshot auf nicht mehr vorhandene IDs zeigen und dadurch eine leere Ansicht ergeben; der Fix ordnet IDs nicht heuristisch neuen Findings zu. Die bestehende Scopebindung verhindert eine Anwendung auf einen anderen Timeslot. Eine kuenftige ueber Reimport stabile Snapshotmigration benoetigt eine ausdrueckliche Identitaets- und Konfliktregel.
GPO-Varianten 2026-09-14: Pass/Fail gilt nur fuer die beruecksichtigte Baselineauswahl und importierte Backup-Konfiguration, nicht fuer einen unbekannten Geraetezielbestand oder effektive Sicherheit. Unvollstaendige Kandidatenerfassung und unbekannte strukturelle Teilanalysen verhindern belastbare Einigkeit. Bekannte wertunabhaengige ADMX-/Baseline-Domaenen-/Zeitmetadatenluecken bleiben diagnostiziert, blockieren aber technisch lesbare Werte nicht. Vor einer Aenderung bei unterschiedlichen Empfehlungen ist die passende Vorlage festzulegen. Historische Ergebnisse benoetigen nach Backendwechsel eine explizite Neuauswertung; keine reale Bestandsauswertung oder PostgreSQL-Integrationsabnahme in diesem Auftrag.
Timeslotvergleich 2026-09-11: Der bisherige Zweilaufadapter kann Quellenidentitaet und Processingpruefungen umgehen; Regelmatches sind nicht objektgenau, und Abwesenheit wird teilweise als Behebung gewertet. Die Score-Pruefpopulation erkennt Assetwechsel nicht ausdruecklich. Teilweise Folgescans koennen alte Verpflichtungen aus der Anzeige verdraengen, ohne Originaldatensaetze zu loeschen. Deshalb keine Abnahme als belastbarer A/B-Bericht und keine blosse UI-Verdrahtung vor P2-P4. Das neue offene Arbeitspaket dokumentiert die Schutzregeln und Gegenbeispiele; alte Abschluss-/Testnotizen ersetzen diese Abnahme nicht.
Accepted Risk 2026-09-10: Deklarierte Namen und Nachweisreferenzen sind keine authentifizierten Signaturen oder geprueften Berechtigungen. Bestehende Projektloeschung und Datenbankadministration begrenzen den Schutz der JSONB-Historie. Es gibt noch keinen unabhaengigen Archivierungs-/Legal-Hold-Vertrag und keinen Fristenbenachrichtigungsdienst. Diese Grenzen sind im Dialog und in `SECURITY.md` dokumentiert; der Ausbau bleibt Teil von Phase 14. Synthetische Repository-/SQL-Transaktionstests ersetzen keine freigegebene PostgreSQL-Integrationsabnahme.
Purple-Knight-Begleitdateien 2026-09-10: Alte Importe koennen den nativen Wert noch nicht als Kontextbeobachtung enthalten. Dann kann die neue Leseprojektion ihn nicht nachtraeglich aus Rohtext erfinden und die betroffenen Summary-/Findings-Dateien muessen gemeinsam neu importiert werden. Mehrere unterschiedliche Ergebnisberichte in demselben Scanlauf werden bewusst nicht automatisch mit einem einzelnen Summarywert verbunden.
Weitere Scanner 2026-09-10: Unbekannte Formate und anders skalierte oder mehrdeutige Kennzahlen benoetigen eine belegte Profilzuordnung; beliebige Scorefelder werden nicht automatisch als Gesamtwert interpretiert. Pflichtdeklaration und synthetische Regressionen sichern den Vorrang unterstuetzter nativer Werte, ersetzen aber keine fachliche Pruefung neuer Herstellerformate.
@ -1968,6 +2255,8 @@ Festgelegtes Client-Info-Darstellungs- und Bewertungsrisiko vom 2026-09-07: Eine
Festgelegtes Workspace-Navigationsrisiko vom 2026-09-07: Ein aus dem aktiven Fachblatt abgeleiteter oder gegen dessen Treffer nachgezaehlter Source-Filter kann alle anderen Scanner ausblenden; ein blosser Fremdwertfilter auf dem GPO-Blatt wuerde wiederum eine leere Ansicht statt des gewuenschten Scans erzeugen. Gegenmassnahme: Optionsliste ausschliesslich aus dem vorhandenen projektweiten Aggregat bilden, die stabile Provenienz-ID von Spaltenindizes entkoppeln, ihre Werte von lokaler Nachzaehlung ausnehmen und bei einer Auswahl aus einem Fachblatt zuerst auf dasselbe Aggregat wechseln. Regressionen pruefen drei unterschiedliche Scans, unveraenderte globale Optionen und das Navigationsziel.
Festgelegtes GPO-Kontextregressionsrisiko vom 2026-09-14: Eine GPO-Auswahl ueber den workspaceweiten Source-Filter kann auf dem All-Records-Blatt zwar die Datensaetze reduzieren, aber weiterhin das zusammengefuehrte Multiple-Tools-Profil und dessen bereits begrenzte Facetten anzeigen. Dadurch erscheinen fachfremde Statuswerte und Scannerfilter; ausgeblendete Altfilter koennen unbemerkt weiterwirken. Ein profilweiter Wertvergleich ueber alle Felder kann zudem `Pass` aus Comparison oder Historical result als aktuellen Passed-Status missverstehen. Gegenmassnahme: den eindeutigen Quellwert ueber Datensatzprovenienz als Werkzeugkontext aufloesen, den GPO-Facetten- und Statusvertrag vor Priorisierung und Limit anwenden, GPO-Status ausschliesslich aus dem aktuellen Statusfeld bestimmen, inkompatible gesetzte Facetten entfernen und den exakten Source-Auswahlweg samt fremdem Pass-Wert und Rueckkehr zum Mischkontext dauerhaft synthetisch pruefen.
Festgelegtes Facettenrisiko vom 2026-09-07: Die allgemeine Einwertigkeitspruefung oder ein kuenftiges Facettenlimit koennte `Source: Scan` erneut aus GPO- oder anderen Ansichten entfernen; flexible Gruppenbreite koennte den Filter ausserdem optisch zwischen Fachfacetten statt stabil ganz rechts platzieren. Gegenmassnahme: Source-Scan fachlich explizit erkennen beziehungsweise aus vorhandener Provenienz ableiten, von der Nuetzlichkeitsunterdrueckung ausnehmen, nach dem begrenzten Fachfacettensatz als letztes Element anfuegen und per eigener CSS-Klasse mit automatischem linken Rand an die rechte Kante setzen. Synthetische Tests pruefen GPO-Vollreihenfolge, Fallback, Klasse und Endposition.
Festgelegtes GPO-Quellenpfadrisiko vom 2026-09-07: Source-Locators koennen lokale Laufwerke, Backupordner, Artefaktnamen oder kundenirrelevante Importstruktur vor der eigentlichen GPO-GUID enthalten; eine tolerante Teilanzeige koennte diese Informationen offenlegen. Gegenmassnahme: zentraler reiner Presenter mit geschlossener GUID-/`DomainSysvol/GPO`-Erkennung, Anzeige ausschliesslich ab dem erkannten internen Anker und fail-closed Platzhalter fuer jede andere Form. Originalprovenienz bleibt unveraendert gespeichert und wird nicht zur Navigation oder Dateiaufloesung verwendet.
@ -1999,6 +2288,7 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
| Ein lokal bereitgestellter Policy-Regelkatalog kann nach fachlicher Freigabe unbemerkt veraendert, nur teilweise konfiguriert, als Symlink umgebogen oder mit ungueltigen Runtime-Strukturen geladen werden und dadurch andere Findings erzeugen. | Pfad und SHA-256 als untrennbares Konfigurationspaar verlangen, nur regulaere Nicht-Symlink-Dateien bis 5 MiB lesen, Hash vor UTF-8-/JSON-Parsing vergleichen und danach den geschlossenen Katalogvertrag fail-closed validieren. Bei jedem Fehler vor Repository- und Serverstart abbrechen; Evaluation Runs bewahren weiterhin den vollstaendigen tatsaechlich verwendeten Katalog. |
| Historische GPO-Reprocessing-Ketten koennen alte Konflikte, Reviewhinweise, Abweichungen und Findings mehrfach als aktuellen Zustand erscheinen lassen; wenn UI und Aktionsableitung unterschiedliche Auswahlregeln verwenden, bleiben ausserdem veraltete Evaluationen bedienbar. | Aktive Profile und nicht ersetzte Identity-Analysen je Artefakt als Wurzel einer gemeinsamen deterministischen Aktivkette verwenden; nur dazu passende Aggregationen, jeweils neueste Evaluationen und Finding-Runs in die aktuelle Sicht aufnehmen. Historie getrennt erhalten und dieselbe Auswahlfunktion fuer Summary, Review Queue sowie Evaluate-/Finding-Aktionen regressionspruefen. |
|---|---|
| Scannertexte koennen `U+0000` enthalten: Browser und In-Memory-Vorschau akzeptieren den Wert, PostgreSQL `jsonb` weist ihn beim Abschluss ab. Eine nur an der Datenbank vorgenommene Bereinigung wuerde ausserdem Vorschau und gespeichertes Modell auseinanderlaufen lassen. | Das Zeichen einmal an der gemeinsamen kanonischen Importgrenze in die explizite Darstellung `\\u0000` ueberfuehren, Anzahl und Repraesentation diagnostizieren und Hash/Locator unveraendert erhalten. Maester-XLSX und kompletter Preview-/Complete-HTTP-Pfad bleiben permanente Regressionen; Abschlussfehler duerfen die erfolgreiche Vorschau nicht mehr verwerfen. |
| Unterschiedlich hohe oder geschlossene Client-Info-Karten koennen in einem regulaeren Zeilenraster grosse Leerraeume hinterlassen; eine unbedingte JS-Masonry-Aktivierung koennte ohne Mess-API dagegen Karten ueberlagern. | Mit dem normalen Raster starten, Kartenhoehen inklusive gemeinsamem Segmentabstand messen und erst danach 1-px-Spannen mit `grid-auto-flow: row dense` aktivieren. Raster und jede Karte beobachten, nur geaenderte Spannen schreiben und bei fehlendem ResizeObserver im sicheren Standardraster bleiben. Synthetische DOM-Hoehen und Collapse werden regressionsgeprueft. |
| Infrastruktur-Fakten koennten bei einer spaeteren Dashboardverdichtung erneut mit Risiko-Mengenwerten in einer ueberlangen Mischkarte zusammengefuehrt oder zwischen beiden Karten doppelt dargestellt werden. | `Infrastructure & Resilience` ausschliesslich mit `risk.slice(1)` speisen und den vollstaendigen Faktenblock genau einmal in `Infrastructure Details` rendern. Instanzzahl, beide Titel, Trennung und vollstaendige Snapshot-Faktenliste statisch regressionspruefen; beide Karten ueber den gemeinsamen Collapse-Test absichern. |
| Maester-Fehlertexte koennen PowerShell-Kontext, HTTP-Antwort und verschachteltes JSON in einer langen Zeile mischen; eine aggressive Umformatierung koennte dabei Diagnosewerte verlieren oder normale Ergebnisprosa faelschlich als Fehler behandeln. | Nur die eindeutige Kombination aus Maester, Ausfuehrungsfehlerformulierung und `Invoke-MgGraphRequest` strukturiert parsen. Die Primaermeldung und bekannte technische Felder additiv extrahieren, den unveraenderten Rohtext stets einklappbar erhalten und bei jeder Nichterkennung auf den generischen Renderer zurueckfallen. Parser und Darstellung mit synthetischen Fehlern regressionspruefen. |
@ -2205,6 +2495,68 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll
2026-09-20: Maester-Import mit nativem Unicode-Nullzeichen repariert (Phase 13A/13C). Die reale PostgreSQL-Fehlermeldung belegte `unsupported Unicode escape sequence` fuer `\\u0000` im Finding-JSONB. `escapeJsonUnicodeNulls` arbeitet rekursiv an der kanonischen Modellgrenze und repraesentiert jedes Vorkommen sichtbar als `\\u0000`; `unicode-null-escaped` dokumentiert Anzahl und Darstellung pro Importdatei. Der Auto-Import behaelt eine bereits erfolgreiche Vorschau bei einem Abschlussfehler, statt eine leere Fehler-Vorschau zu erzeugen. 26 fokussierte Maester-/HTTP-Tests, beide Produktionsbuilds, Local-only- und Designpruefung erfolgreich; der einmal parallel gestartete zusaetzliche npm-Testlauf traf einen Windows-`spawn EPERM`, die identischen fokussierten Tests liefen davor und danach erfolgreich. Keine Migration oder Bestandsdatenmutation; laufender Backendprozess benoetigt den aktualisierten Build.
2026-09-20: Mengenbalken fuer die Security-Domain-Verteilung in Client Info umgesetzt und nach Nutzerkorrektur von Text und Zahl getrennt sowie mit dem Overall-Score-Donut farblich gekoppelt (Phase 7). `DashboardDomainRow` verwendet seinen ohnehin sichtbaren Abweichungs- oder Review-only-Wert; `DashboardPanel` bildet einmal die Summe der angezeigten Domains und uebergibt sie als Nenner. Ein gemeinsames Vier-Spalten-CSS-Subgrid bemisst Score, laengsten Domainnamen, Balkenflaeche und breiteste gerenderte Zahl. Das dekorative `aria-hidden` Balkenelement belegt ausschliesslich die dritte Spalte, endet bei jeder Zeile an derselben Kante und waechst nach links. `dashboardScoreTone` setzt denselben Overall-Tone am Posture-Bereich und am Donut; alle Balken lesen dessen `--chart-color` exakt, waehrend Domainknoten ihre eigene Tone-Klasse behalten. Ein synthetischer 3:1-Fall sichert exakt `75 %`/`25 %`, alle acht Katalogzeilen sowie Subgrid- und gemeinsame Tone-Geometrie. 23 fokussierte Tests, Designsprachpruefung und Client-/Server-Produktionsbuild bestanden. Live am Dummy: neun getrennte Zeilen, je rund 12 px zwischen Text/Balken und Balken/Zahl, Donut und alle Balken exakt `rgb(232, 33, 79)` in Light/Dark, keine Ueberlaeufe; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema-, Klassifikations-, Score- oder Drilldownmutation.
2026-09-20: Verbindliche 14-/16-px-Lesetypografie in Client Info umgesetzt (Phase 7, Nutzerentscheidung). `AGENTS.md` unterscheidet nun primaere Informationslabels und strukturierte Tabellen-/Kachelinhalte mit mindestens 14 px von lesbaren Werten und Fliesstext mit 16 px; nur Controls, technische Metadaten und Badges duerfen kompakter bleiben. Environment Detail verwendet die Rollen fuer Faktlabels/-werte, Source-Overview-Metadaten, Operating-systems-Hinweis, Gruppen, Tabellenkoepfe und Quellspalten. Synthetische CSS-Vertragstests sichern die Rollen. 23 fokussierte Tests, Designsprachpruefung und Client-/Serverbuild erfolgreich. Live in Light und Dark: `Domain` 14 px/700, `sevenkingdoms.local` 16 px/400, Source-Information und OS-Tabelle 14 px, kein horizontaler Ueberlauf der Directory-Kachel; Light wiederhergestellt. Keine Daten-, Import-, API-, Persistenz-, Schema- oder Bewertungsmutation.
2026-09-20: PingCastle-Zustandswerte in Client Info ergaenzt (Phase 7, Nutzererweiterung). Die bestehende Operating-systems-Tabelle fuehrt vier neue strukturierte Zahlenspalten `Active`, `Inactive`, `Enabled` und `Disabled`. `operatingSystemInventory` liest die bereits verlustfrei gespeicherten PingCastle-Labels nur aus exakt derselben Source Row wie Betriebssystem und `Nb OS`; mehrdeutige, fehlende, negative oder fremdzeilige Werte bleiben unbekannt. Ein synthetischer Extraktionsfall und zwei UI-/Joinregressionen sichern vollstaendige Darstellung sowie das Verbot quellenuebergreifender Uebernahme. 28 fokussierte Tests, Design-/Local-only-Pruefung und Client-/Serverbuild erfolgreich. Live read-only am Dummy bestaetigt `Windows Server 2019 1809` mit Devices/Active/Inactive/Enabled/Disabled `1/1/0/1/0`, getrennte unquantifizierte Quelle mit Gedankenstrichen, kein horizontaler Ueberlauf bei 568 px sowie lesbare Light-/Dark-Rollen; Light wiederhergestellt. Keine Import-, API-, Persistenz-, Schema- oder Datenmutation.
2026-09-20: Deviation-Anzeige zwischen Data Hub und Client Info vereinheitlicht (Phase 7, Nutzerkorrektur). Die Data-Hub-Statuszeile laedt nun den explizit ausgewaehlten gemischten Timeslot inklusive assessmentgebundener GPO-/Policy-Beobachtungen und wertet dessen Projektblatt mit derselben aus React herausgezogenen Zustandsklassifikation wie Client Info aus. Die Timeslot-Auswahl traegt dafuer neben Run-IDs auch Assessment-IDs; GPO bleibt fachlich eine eigene Quelle und wird nicht in einen Scanner-Run umgedeutet. Client Info verwendet fuer `Deviations` dieselbe `datahub-reference-knob`-Variante ohne lokale Flaechen-/Typografieueberschreibung. Ein synthetischer Mischfall sichert Scanner- plus GPO-Deviations dauerhaft; weitere Quelltext- und Interaktionstests sichern Scope, Rendering und Work-Drilldown. 32 fokussierte Tests, Design-/Local-only-Pruefung und Client-/Serverbuild erfolgreich. Live am Dummy: Data Hub und Client Info jeweils `477 / 1.833 / 26 %`; Light-/Dark-Styles fuer Flaeche, Ring, Padding, Typografie und Farben identisch, Light wiederhergestellt. Keine Daten-, API-, Persistenz-, Schema- oder Bewertungsmutation.
2026-09-20: Violett als UI-Rolle vollstaendig entfernt (Nutzerentscheidung 2026-09-20, AGENTS.md "UI segments and tiles", reiner Darstellungsfix ueber bereits aufbewahrten Fakten). Die violette Akzentfamilie `--accent`/`-hover`/`-active`/`-on`/`-subtle` ist aus `src/styles/ui-tokens.css` geloescht statt umgeleitet, weil jeder ehemalige Verbraucher direkt auf die passende Rolle umgestellt wurde: Tastaturfokus (Eingabefelder, `.segmented__item`) auf `--focus-ring`, Auswahl-/Aktivzustand (Segmented Tabs, Finding-Card-Auswahl, Checkboxen, Ergebnisfenster-Umriss, diverse Hoverzustaende) auf `--marking-color`/`--button-marked-bg`/`--button-marked-text`. Zusaetzlich identifiziert und behoben, weil dieselbe verbindliche Regel jeden violett aufloesenden Token verbietet: die violett getoente Auswahlflaeche `--surface-selected` (jetzt aus `--marking-color` gemischt, betraf u. a. generische `.row`/`.table`-Auswahl, Data-Hub-Verlaufszeilen und die Checkbox-Indeterminate-Flaeche), die violetten Pathfinder-Graph-Knoten-/Kantenfarbe `--graph-relation` sowie der violette Results-Action-Umriss `--border-results-action`/`--text-results-action`, beide neu auf einen dedizierten, nicht-violetten Ton bzw. auf `--marking-color` gestellt, der violette Donut-/Client-Info-Status `--donut-not-applicable` (inkl. der irrefuehrend benannten Alias-Rolle `--client-info-purple`) auf einen dedizierten Rosaton, der violette Diagrammton `--viz-1` auf Oliv-Gruen sowie der zu nah an Violett liegende Orchid-Ton `--viz-6` auf ein eindeutiges Pink. Ein neuer Token `--chart-tone-emphasis` (beide Themes) ersetzt die Wiederverwendung von `--accent` fuer Kategorie-/Chart-Flaechen (PKI-Chart-Ton `.tone-pki`/`.tone-accent`, IOE-Summenzahl, Bar-Track- und Ring-Fallback, `origin-pill.native`). Zwei hartkodierte Hex-Paletten fuer die Fensterunterscheidung gleichzeitig geoeffneter Result-Fenster (`DatasetViewer.tsx`, `MitreGraphView.tsx`, `ObsidianGraphView.tsx`) enthielten ebenfalls violette Eintraege und sind ersetzt. Reine Farbtoken-/CSS-Aenderung ohne Scoring-, Persistenz- oder API-Beruehrung, daher keine Reimport-Manifestversion. Eine bestehende Regression in `tests/features/obsidian-graph.test.ts` pruefte woertlich den alten violetten Hex-Wert und ist auf eine themenoffene, aber violett-ausschliessende Pruefung umgestellt. `npm test`: 1.416 bestanden, 13 uebersprungen, derselbe fachlich unabhaengige, durch isolierten Stash-Vergleich bestaetigte Vorbestand (`tests/features/gpo-xml-presentation.test.tsx`) faellt nachweislich auch ohne diese Aenderung; Client- und Serverbuild erfolgreich. Keine Live-Browserabnahme durchgefuehrt, Verifikation ausschliesslich ueber Tokenverfolgung, Build und Testlauf.
2026-09-18: Client Info tatsaechlich an die freigegebene Ableton-Designsprache angeglichen (Phase 7, Folgeauftrag zur Designverankerung). Gemeinsame Theme-/Statusrollen, flache eckige Tiles, standardisierte Aktionen, kompakte Quellenkacheln, aufklappbare Environment-/Risiko-Details und ruhigere Domaenenzeilen. Die vom Nutzer ausdruecklich geschuetzten Overall-zu-Domaenen-Kurven bleiben dynamisch vermessen. 47 fokussierte synthetische Tests, Client-/Server-Typechecks, separater Client-Produktionsbuild, Design-/Local-only-Pruefung und synthetische Browserabnahme beider Themes bei vier Breiten bestanden. Keine Daten-, Scoring-, API-, Persistenz- oder Dienstenaenderung. Einschraenkungen und Messumfang im aktuellen Status und Risikoabschnitt dokumentiert; Phasenstatus unveraendert.
2026-09-18: Ableton-Designvertrag auf ausdrueckliche Nutzerkorrektur verankert (Phase 7). AGENTS.md benennt die freigegebenen Data-Hub-, Work- und Pathfinder-Ansichten als verbindliche Referenzen fuer alle Ansichten und Designartefakte, einschliesslich Client Info. Die bisherige visuelle Client-Info-Ausnahme und der verpflichtende dunkle Faktenstreifen sind ausdruecklich supersediert. Lokaler assessor-ui-Skill und seine Referenzdokumente benennen den Vorrang der aktuellen Projektregeln; die README-Kopieranleitung fuer veraltete Skill-Assets wurde entfernt. Die drei generischen Client-Info-Mockups gelten als verworfen. Pruefumfang: Dokumentkonsistenz, relative Verweise und Patch-Whitespace; keine Runtime-, CSS-, Komponenten-, Daten- oder Persistenzaenderung, deshalb kein Build und keine behauptete visuelle Abnahme. Fachliche Client-Info-/Scorevertraege und vorhandene Phasenstatus bleiben erhalten.
2026-09-18: Management-Overview-Renderer umgesetzt (Artefakt A der Berichtsstrecke, `docs/REPORT_STRATEGY.md`). `src/exports/management-overview-report.ts` erzeugt ein selbsttragendes, statisches HTML-Dokument in Deutsch: kein Script, kein Inline-Handler, keine externe URL, kein Link, kein `url()` und damit kein Netzwerkzugriff beim Oeffnen. Die Abschnittsfolge ist Assessment-Identitaet, Overall Posture mit Security Domains, Priority Findings, Remediation-Status, Abdeckung und Luecken, Source Overview. Priority Findings sind nach Security Domain gruppiert und innerhalb je Quelle nach deren deklariertem Maßstab geordnet, der im Dokument benannt wird; eine quellenuebergreifende Rangfolge entsteht nicht. GPO bleibt gleichgewichtig mit Verdiktzaehlern, getrennten Conflict-/Duplicate-Angaben, ausgewiesener Score-Neutralitaet und Scope-Hinweis. Der Remediation-Abschnitt entfaellt ohne Reviews und ist sonst ausdruecklich als Selbstauskunft ohne technischen Nachweis gekennzeichnet. Verbindliche Scoreformulierung uebernommen; `Baseline-Compliance` und `Baseline-Abdeckung` sind durch Tests ausgeschlossen. Unbekanntes rendert als Gedankenstrich, ein fehlender Score zeichnet keinen Nullbogen. Donutgeometrie entspricht dem Vertrag: volle 360 Grad, neutraler Track, Wertbogen im Uhrzeigersinn ab zwoelf Uhr, flache Enden, zentrierter Wert mit kleinerer Einheit; Bereichsueberschreitungen werden geklemmt. Ausschliesslich Tokenwerte aus `src/styles/ui-tokens.css`, Light als Dokumentstandard und Dark ueber `prefers-color-scheme` aus denselben Rollen, eigener Druckzweig. 17 synthetische Tests einschliesslich Struktur-, Escaping- und Donutgeometriepruefung bestanden; Client- und Serverbuild, Local-only- und Designpruefung erfolgreich; Gesamtlauf 1.399 bestanden, 13 uebersprungen, derselbe fachlich unabhaengige Vorbestand (`tests/features/gpo-xml-presentation.test.tsx`) faellt weiterhin auch ohne diese Aenderung. Abweichung benannt: Ein ausgeliefertes Dokument verwendet bewusst keine Applikationsshell (Sidebar, Topbar, Command Band) und keinen der vier Seitenarchetypen; Typografie-, Donut-, Tile-, Segment- und Statusregeln gelten unveraendert. Rein additiv, kein Reimport erforderlich.
2026-09-18: Prioritaetsbasis als Profilpflicht deklariert (Berichtsstrecke, Festlegungen in `docs/REPORT_STRATEGY.md`). `ToolPriorityBasisDefinition` ist auf jeder `ToolProfileDefinition` verpflichtend und nennt exakte gemappte Eigenschaften, Skala, Richtung und Semantik oder einen ausdruecklichen Grund, warum keine belastbare Basis existiert. Purple Knight und Maester deklarieren native Severity, PingCastle deklariert Points, weil seine Severity abgeleitet und bauartbedingt bei `high` gedeckelt ist, CIS-CAT und Generic deklarieren `unavailable` mit Begruendung. Zusaetzlich werden ausgeschlossene Eigenschaften mit Grund festgehalten: CIS `Level 1`/`Level 2` ist eine Haertungsprofilstufe und darf nie zur Kritikalitaet befoerdert werden, PingCastles Punkteschema-Beschreibung und Domainrisiko sind keine Einzelfundraenge. `reportPriorityBasisFrom` macht aus einer `unavailable`-Deklaration eine ausdrueckliche `equal-weight`-Basis mit der Profilbegruendung, sodass ein Bericht die Unranked-Lage benennt statt eine fehlende Deklaration zu suggerieren. Zehn zusaetzliche synthetische Tests einschliesslich Vertragspruefung aller registrierten Profile bestanden; Client- und Serverbuild erfolgreich; Gesamtlauf 1.382 bestanden, 13 uebersprungen, derselbe fachlich unabhaengige Vorbestand (`tests/features/gpo-xml-presentation.test.tsx`) faellt weiterhin auch ohne diese Aenderung. Rein additive Profilfelder: keine Erkennung, Extraktion, Zuordnung, Normalisierung, Bewertung oder Persistenz veraendert, daher kein Reimport erforderlich.
2026-09-18: Report-Projektion als plattformneutrales Domain-Modul umgesetzt (neue Berichtsstrecke, noch keiner Phase zugeordnet; Festlegungen in `docs/REPORT_STRATEGY.md`). `src/domain/report-projection.ts` liefert die gemeinsame Grundlage fuer Management Overview, Technical Report, Arbeitsgrundlage und Delta Report: sprachneutral, ohne Uhr, Speicher, Netzwerk oder Praesentationstypen. Prioritaet wird nie quellenuebergreifend gebildet; eine deklarierte Severity-Basis ohne Severity-Abdeckung faellt nachweisbar auf `undeclared` mit eigener Diagnose zurueck. GPO bleibt gleichgewichtig, selbstgemeldete Reviewstatus werden nie zu behobenen Findings, und ein Delta entsteht ausschliesslich aus einem gespeicherten Vergleich mit gewaehltem Referenzlauf. Die Sicherheitsdomaenen-Zuordnung wird injiziert, damit die Domaenenschicht nicht auf die Featureschicht zeigt. 14 neue synthetische Tests bestanden, Client- und Serverbuild erfolgreich; Gesamtlauf 1.372 bestanden, 13 uebersprungen, ein fachlich unabhaengiger Vorbestand (`tests/features/gpo-xml-presentation.test.tsx`) faellt auch ohne diese Aenderung. Rein additiv: keine bestehende Datei, kein Schema, keine Pipeline und keine Kundendaten beruehrt.
2026-09-18: Vollstaendigen Client-Info-Security-Domain-Katalog fuer Work freigegeben (Phase 7, Nutzerkorrektur). Alle acht Domains bleiben mit exakten gemeinsamen Labels und auch bei null Treffern auswaehlbar; aktive Status-/Facettenauswahlen bleiben bei leeren Schnittmengen erhalten. Client-Info-Deviation-Domaindrilldown auf sichtbaren gemeinsamen Filter umgestellt, feste Record-ID-Einschraenkung dort entfernt. Vier permanente synthetische UI-Regressionen und 40 fokussierte Tests bestanden; keine Mandantendaten oder Klassifikations-/Persistenzlogik veraendert.
2026-09-18: GPO-Zaehlerabgleich Result DB/Work (Phase 7/13B) umgesetzt: SQL-Laufauswahl auf verschiedene verglichene Baseline-Pakete angeglichen und anzeigewertbasierte Unterdrueckung autoritativer Fail-Urteile entfernt. 50 fokussierte Tests einschliesslich fuenf real ausgefuehrter synthetischer PostgreSQL-SELECT-Faelle, zwei Work-Einstiege und Result-DB-Interaktion gruen; beide Produktionsbuilds und Local-only-/Designpruefung bestanden. Dummy ausschliesslich lesend abgenommen: 127 Vergleiche zu 25 Einstellungen, gleiche aktuelle Run-ID, keine versteckten Fail-Eintraege oder zusaetzlichen Paare. Keine Migration oder Datenmutation. Bestehende fremde XML-Reader-Testabweichung bleibt dokumentiert; keine Phasenabnahme behauptet.
2026-09-18: Result-DB-Filterbenennung mit Work vereinheitlicht und Deviation-Ausnahme entfernt (Phase 7/13B, Nutzerkorrektur). Gemeinsame Labels in Work, Result-DB-Facette und Details; Zaehler beachten Deviation, Statusaktionen erhalten die gesetzte Auswahl, Nulltrefferfilter bleiben abwaehlbar. Zwei permanente synthetische Browserkomponenten-Interaktionstests decken beide Timeslotarten ab. 45 fokussierte Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Dateninspektion, Bestandsmutation, SQL-Migration oder Bewertungsveraenderung.
2026-09-18: Ueberhang langer Security-Template-Registrytitel behoben (Phase 7/13B). `PathText` erkennt neben `registry ·` nun den tatsaechlich angezeigten Vertrag `Registry Values · MACHINE\\...`/`USER\\...`, ohne allgemeine Security-Template- oder AppLockertexte umzudeuten. Reader-Heading und Summary nutzen damit die begrenzte technische Pfaddarstellung und ueberlagern `Accepted Risk` nicht mehr. Zwei synthetische Regressionen ergaenzt; 1.351 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen, Produktionsbuild erfolgreich. Keine Dateninspektion, Bestandsmutation, Migration, Reimport oder Neuauswertung.
2026-09-18: GPO-Mehrfachdarstellung in Work behoben (Phase 7/13B). Neun Baselinepakete derselben Einstellung ergeben eine Work-Zeile mit allen Einzelvergleichen und genau einer Quellanzeige. Gemeinsame Urteilprioritaet, individuelle Baselinefacette, paketbezogene Sollwerte, sichere Wertmarkierungen, historische Readiness und Erhalt frueherer lokaler Reviews implementiert. Zehn neue synthetische Regressionen; Gesamtsuite 1.349 bestanden, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Builds erfolgreich. Keine Dateninspektion, Bestandsmutation, Migration, Reimport oder Neuauswertung; Result DB und gespeicherte Baselinevergleiche unveraendert.
Verifiziert 2026-09-18 (Phase 13B, Work-Listenstabilitaet und lange Registrytitel): Die einzelne FindingCard fuehrt bei Auswahl kein eigenes glattes `scrollIntoView` mehr aus; der virtuelle Listencontainer bleibt alleinige Scrollautoritaet und springt nur bei ausserhalb liegenden Eintraegen ohne Animation. `scrollbar-gutter: stable` reserviert die Scrollbarspur der Datensatzliste und verhindert wechselnde Textbreite. `PathText` erkennt nun den bestehenden Titelvertrag `registry · <Pfad>` und rendert den unveraenderten Registrypfad im Finding-Reader und in dessen Finding-Ueberschrift mit der gemeinsamen technischen Pfaddarstellung; volle Breite, `border-box` und bestehendes `overflow-wrap: anywhere` halten lange Pfade innerhalb der Lesespalte, ohne Accepted Risk oder Aktionen zu ueberlagern. Ein Interaktionstest waehlt eine sichtbare mehrzeilige Registrykarte und belegt null kartenlokale Scrollanimationen; Pfad-, Typografie- und GPO-Darstellungstests sichern den zweiten Screenshotfall. `npm test`: 1.339 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Bestehende Chunkgroessenwarnung unveraendert; die Laufzeitpruefung blieb auf den vom Nutzer gezeigten LSW-Kontext begrenzt, Bestandsdaten wurden nicht veraendert. Keine API-/SQL-/Persistenz-/Fachlogikaenderung.
GPO-Quellwiederherstellung 2026-09-17 (Phase 13B, Nutzerauftrag, verifiziert): Fehlende GPO-Artefaktdateien ergeben jetzt bereits beim Statuslesen Upload required mit gezieltem ZIP-/Ordnerupload; Zugriffsprobleme bleiben separat Storage unavailable. Reconciler, Retry und Reprocess pruefen vor dem Einreihen, nachtraeglich fehlende Quellen beenden einen bereits gestarteten Job nach einem Versuch. Der Originalupload prueft Projekt/Artefakt/Timeslot, Groesse und SHA-256, repariert auch den normalen Dublettenpfad und stellt nur fehlende Bytes atomar unter derselben Speicher-ID wieder her. Aktuelle Analysen, Originalzeitpunkte und Historie bleiben erhalten; reine Re-evaluation ueber erhaltene Eingaben bleibt erreichbar. 16 neue synthetische Regressionen sichern Service, HTTP, API-Client, beide Oberflaechen mit ZIP/Ordner, automatische Reparatur, ersten Fehler, Zugriffsfehler, falsche Quelle, fremde Scopes, Wiederherstellung ohne Revisionswechsel sowie Local-/PostgreSQL-Jobverhalten und konkurrierende Dateiwiederherstellung. npm test: 1.336 bestanden, acht optionale PostgreSQL-Integrationstests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Kein Parser-/Analyzer-/Manifestbump, keine SQL-Migration, kein Zugriff auf Kundeninhalte, keine Live-Datenmutation und kein Backendneustart. Bestehender Bundlegroessenhinweis bleibt; Phase 13B bleibt in Arbeit.
Verifiziert 2026-09-17 (Phase 13B, Originalschreibweise durchgaengig bis Work): Der Parser-Fix allein reichte nicht. Der angezeigte Titel eines bewerteten Settings stammt aus der Baselineseite, deren Analysen mit dem alten Parser erzeugt worden waren; die Kundenbeobachtungen trugen die Originalform bereits, die Work-Zeile weiterhin nicht. Baselineanalyzer append-only auf 1.4.0 erhoeht, elf Baselinepakete neu analysiert, neu ausgewertet: 593 von 593 Security-Template-Zeilen tragen jetzt die geschriebene Form. Zwei Folgefehler derselben Klasse dabei behoben. Erstens waehlte die automatische Pipeline alle nicht abgeloesten Analysen und verglich vier Baselines doppelt, weshalb jeder frische Lauf sofort wieder als re-evaluierungsbeduerftig galt; die Auswahl kollabiert jetzt ausschliesslich Generationen desselben Pakets, wodurch die dokumentierte Vereinbarung zu unabhaengigen aktiven Analysen woertlich erhalten bleibt und ihr Vertragstest unveraendert besteht. Zweitens bevorzugte die Laufauswahl den Lauf mit mehr Analyse-Referenzen, wodurch der veraltete Lauf mit vier doppelten Generationen den korrigierten verdraengte; gemessen wird jetzt die Zahl tatsaechlich verglichener Baselines, mit Rueckfall auf die deklarierten Referenzen, wenn die Ergebnisse keine Baseline nennen. Ausserdem festgestellt und behoben: der konfigurierte Artefakt-Store war leer, weil die Quelldateien geschrieben wurden, als ASSESSOR in der Codex-App lief und Windows LOCALAPPDATA in den Paketcontainer umleitete; die dreizehn Objekte wurden pruefsummenverifiziert in den konfigurierten Store kopiert, das Original blieb unangetastet. Nebenbefund ohne Fix: die Persistenz behauptet `backend-controlled`-Aufbewahrung, ohne die Erreichbarkeit der Bytes zu pruefen, was sich erst nach drei verbrauchten Versuchen als Dead-Letter zeigt statt als `not-retained`. `npm test`: 1.320 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Work-Abdeckung unveraendert vollstaendig: 3.552 Beobachtungen, null ohne Zeile. Keine SQL-Migration, keine Evaluatoraenderung, keine Bestandsloeschung.
Verifiziert 2026-09-17 (Phase 13B, Originalschreibweise nativer Policyeinstellungen): Der Security-Template-Parser schrieb den Abschnittsnamen beim Parsen klein und verwarf die Originalform; aus `[Privilege Rights]` wurde unwiderruflich `privilege rights`. Das verletzte die Regel, dass Normalisierung keine Quelldaten zerstoeren darf. Der Parser behaelt jetzt die geschriebene Form von Abschnitt und Schluessel; die case-gefalteten Formen bleiben intern fuer Grammatikauswahl, Gruppierung und Identitaetsdigest, wodurch bestehende Beobachtungs- und Identitaets-IDs stabil bleiben. Der native Target-Resolver legt die geschriebene Form im aufbewahrten Target ab, genau wie der Registry-Resolver es bereits tat; Work zeigt damit `Privilege Rights - SeCreateTokenPrivilege` und der Wert ist gegen Microsoft-Dokumentation und externe Tabellen suchbar. Die Resolverversion bleibt bewusst unveraendert, weil die Validierung persistierter Identitaeten exakt auf sie prueft und ein Bump historische Datensaetze ungueltig machen wuerde; die Provenienz traegt stattdessen die Analyzer- und Parserversion. Weil sich persistierte Parsingausgabe aendert, ist dies ein Verarbeitungsversions-Schritt: Parser 1.1.0 auf 1.2.0, GPO-Analyzer 2.2.3 auf 2.2.4, Jobprozessor 2.2.3 auf 2.2.4. Damit die Erhoehung nicht still bleibt, meldet der GPO-Status jetzt einen veralteten Analysestand: Data Hub zeigt `Reprocessing required` mit beiden Analyzerversionen und bietet die bestehende Reprocess-Aktion auf der gespeicherten Quelle an, ohne erneuten Upload; abgeloeste Analysen fordern keine Arbeit an. Reprocessing rangiert vor Re-Evaluation, auch in der Client-Readiness. Eine synthetische Abnahmepruefung deckt die exakte Nutzerinteraktion ab. `npm test`: 1.318 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine SQL-Migration, keine Evaluatoraenderung, keine Bestandsmutation; historische Werte bleiben unveraendert erhalten.
Verifiziert 2026-09-17 (Phase 13B, Aufraeumen der Analyseluecken): Vor dem Aufraeumen nachgemessen statt der eigenen Annahme gefolgt. Die Behauptung des vorigen Eintrags, beide Gap-Kacheln seien strukturell leer, war fuer `parser-gap` falsch: `unsupported-setting` wird von jeder Engine 1.3.0 bis 1.8.0 erzeugt, zuletzt 141 Ergebnisse; die Null unter 1.9.0 entsteht erst durch die aufgeloesten Security-Template-Vergleiche gegen die aktuelle Baselineanalyse. Die Kachel `Comparison not supported` bleibt deshalb erhalten und ist mit einer synthetischen Abnahmepruefung gegen stille Entfernung gesichert. Entfernt wurde nur wirklich Totes: die Kachel `Baseline mapping unavailable`, deren `catalog-gap` ausschliesslich Engine 1.2.0 erzeugt hat und die aktive Engine nicht mehr setzen kann, sowie die nirgends importierte Datei `server/gpo/gpo-policy-evaluation-rule-engine.ts`. Die Review-Queue behaelt das Kategorielabel `Baseline mapping unavailable` fuer historische Laeufe; die abgeleiteten Zaehler `catalogGaps`/`parserGaps` bleiben im Domaenenvertrag, weil sie fuer aufbewahrte Historie weiterhin korrekt sind. Die Sektion nennt jetzt Beobachtungsdiagnostik statt eines fehlenden Pass/Fail-Urteils und verweist fuer unaufgeloeste Identitaeten auf das Work-Verdict `Not comparable`. `npm test`: 1.317 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Evaluatorversion, SQL-Migration, Reimportpflicht oder Bestandsmutation.
Verifiziert 2026-09-17 (Phase 13B, Verdict `Not comparable` und Gap-Entdopplung): Die 242 unaufgeloesten Beobachtungen des aktiven Laufs sind ausnahmslos Group Policy Preferences (145 Registry, 70 Drive Maps, 16 Local Users and Groups, 11 Services) und erschienen in Work bisher gar nicht. Auf ausdrueckliche Nutzerentscheidung erhalten sie das score-neutrale Verdict `Not comparable`; die verbindliche GPO-`Result Status`-Liste in AGENTS.md ist mit Zustimmung um diesen achten Wert erweitert und die Semantik dort als eigene Regel dokumentiert. Preferences werden bewusst nicht in Policy-Identitaeten aufgeloest und nicht mit Microsoft-Baselines verglichen: Aktion, Item-Level-Targeting und Apply-once-Semantik unterscheiden sich von einer Policy-Einstellung, ein Baselineurteil darueber waere erfunden. Die Leseprojektion zeigt zusaetzlich die bereits aufbewahrte CSE, Aktion und Targeting-Status; diese bleiben in den vereinbarten GPO-Spalten lesbar, ohne eine neue Spalte oder Facette einzufuehren. Gleiche oder abweichende Preference-Anweisungen sind keine belegten Duplikate oder Konflikte, deshalb traegt `Not comparable` kein Diagnoseflag. Zusaetzlich behoben: die Snapshot-Aggregation meldete jede unaufgeloeste Identitaet ihrer Quellanalysen ein zweites Mal, wodurch dieselben Beobachtungen einmal korrekt und einmal als namenloses `Review required` in der Review-Queue standen; der Kategoriematcher erkannte den Resolver-Code nicht. Beides ist korrigiert, die Gap-Zahl bleibt unveraendert. Zwei neue synthetische Abnahmepruefungen fuer die exakten Nutzerinteraktionen (`Result Status = Not comparable` waehlt die Preferences mit lesbarer CSE, Aktion und Targeting und liefert weder Deviation noch `No baseline rule`; unaufgeloeste Identitaet genau einmal mit autoritativem Grund) plus angepasste Vertragstests. `npm test`: 1.316 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Reine Leseprojektion ueber bereits aufbewahrte Fakten: keine Evaluatorversion, SQL-Migration, Reimportpflicht oder Bestandsmutation. Korrektur zum naechsten Eintrag: die hier als gemeinsam "strukturell leer" bezeichneten Gap-Kacheln sind nicht gleichwertig; `parser-gap` ist erreichbar und bleibt erhalten.
Verifiziert 2026-09-17 (Phase 13B, Verdict `No baseline rule`): Die Evaluationsschleife laeuft ausschliesslich ueber Baselineeintraege; aggregierte Kundenzustaende ohne Gegenstueck fielen bisher lautlos heraus und erschienen in Work weder als Verdict noch als Analyseluecke. Auf ausdrueckliche Nutzerentscheidung erhaelt jeder aufgeloeste Kundenzustand jetzt genau ein Verdict: ein bewertetes Urteil, wo eine Baselineregel existiert, sonst das score-neutrale `No baseline rule`. Die verbindliche GPO-`Result Status`-Liste in AGENTS.md ist mit Zustimmung des Nutzers um diesen siebten Wert erweitert; die Semantik ist dort als eigene Regel dokumentiert und von `Not configured` abgegrenzt. `Conflicts`/`Duplicates` sind konsequent Diagnoseflags auf der Zeile des Settings und ersetzen dessen Verdict nicht mehr; Zaehlung, Facettenauswahl und Result-DB-Projektion folgen dem Flag statt dem Zeilenstatus. Der zuvor dokumentierte stille Abbruch fuer Vergleiche ohne sicheres Urteil ist beseitigt: solche Vergleiche bleiben `Review required`. Beobachtungen, die bereits einen bewerteten Vergleich belegen - einschliesslich Kindwerten einer Registry-Key-Anforderung - werden nicht doppelt gemeldet; unaufgeloeste Identitaeten bleiben ausserhalb des Verdicts und behalten ihre eigene Lueckenmeldung. Neue synthetische Abnahmepruefung fuer die exakte Nutzerinteraktion (`Result Status = No baseline rule` in Work waehlt das konfigurierte Setting mit erhaltenem Originalwert, nicht Deviation oder Not configured) plus angepasste Vertragstests. `npm test`: 1.313 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Reine Leseprojektion ueber bereits aufbewahrte Fakten: keine Evaluatorversion, SQL-Migration, Reimportpflicht oder Bestandsmutation. Offen bleiben die GPP-Kategorie und die strukturell leeren Gap-Kacheln `catalog-gap`/`parser-gap`.
Verifiziert 2026-09-17 (Phase 13B, aktuelle Baselineanalyse-Generation): Die GPO-Re-Evaluation reichte bisher die `baselineAnalysisIds` des vorherigen Laufs unveraendert weiter. Dadurch blieb eine Evaluation dauerhaft an die Baselineanalyse-Generation ihres allerersten Laufs gebunden, auch nach jedem Engine-Upgrade. `gpoEvaluationUpdates` loest jetzt jede aufbewahrte Baselinereferenz auf ihre aktuelle, nicht abgeloeste Analyse auf und erweitert den bewerteten Baselinesatz nie: eine unbekannte Referenz und eine Baseline ohne neuere Generation behalten ihre eigene Analyse-ID. Die Readiness beruecksichtigt zusaetzlich zur Engineversion die Baselineanalyse; `reasons` benennt die Ursache, Data Hub und Client-Readiness zeigen sie getrennt an. Die Auswahl der aktuellen Generation ist mit `buildActions` zu einem gemeinsamen Helfer zusammengefasst. Vier neue synthetische Tests pruefen die exakte Nutzerinteraktion (Re-evaluate-Aktion sendet die neuere Generation, keine Erweiterung des Baselinesatzes, Ursachenanzeige bei reiner Baselineaenderung) und das Verstummen nach erfolgter Neuauswertung. `npm test`: 1.310 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Engineversion, SQL-Migration, Bestandsmutation oder Reimportpflicht; bestehende Laeufe bleiben als Historie erhalten und werden erst durch die ausdrueckliche Re-evaluate-Aktion ersetzt. Abgrenzung in REIMPORT_POLICY.md ergaenzt. Phase 13B bleibt in Arbeit; die uebrigen dokumentierten GPO-Analyseluecken (Kundeneinstellungen ohne Baselineregel, GPP-Kategorie, immer leere Gap-Kacheln) bleiben offen.
Verifiziert 2026-09-17 (Phase 7/13B, Quellenzuordnung): Originalanweisungen je GPO-Backup sofort im Reader sichtbar, native Identitaeten/Quellsequenz aus vorhandener Persistenz projiziert und fehlende Conflict-Kennzeichnung fuer delete-value durch autoritative Zustandsableitung korrigiert. Unabhaengig belegte Unterschiede bleiben bei zusaetzlichen indeterminate-Beitraegen sichtbar, ohne fruehere Einzelanweisungen unsicherer Backups als separate GPOs zu behandeln. Entscheidung und Ablosung der Results-only-Verbergung in AGENTS.md dokumentiert. 20 neue synthetische Tests einschliesslich exakter Work-Interaktion in GPO-Blatt und Source-Scan-Kontext; 1.304 Gesamttests, Local-only-/Designcheck und beide Builds erfolgreich, acht optionale PostgreSQL-Tests uebersprungen. Keine Bestandsmutation oder Reimportpflicht; fehlende GPO-Namen und unbelegte Wirksamkeit bleiben transparente Grenzen.
Verifiziert 2026-09-17 (Phase 7/13B): Den ausdruecklich angeforderten Work-Filter `GPO backups with failing settings` mit Einzel-/Gesamtauswahl umgesetzt und den GPO-Facettenvertrag in AGENTS.md erweitert. Gemeinsame Quellenidentitaet mit der Processing-Kachel, additive Datensatzprovenienz, unveraenderte Vergleiche und historische Statusgrenze. Vier neue synthetische Regressionen; 1.284 Gesamttests, Local-only-/Designcheck und beide Produktionsbuilds erfolgreich, acht optionale PostgreSQL-Tests uebersprungen. Keine Kundeninhalte gelesen oder Bestandsdaten veraendert.
Verifiziert 2026-09-14 (Phase 7/13B, GPO-Baselinevarianten): Evaluator 1.5.0 und additiven Variantenvertrag mit gespeicherten Originalwerten, Einzelurteilen und Quellenreferenzen implementiert. Eindeutiger Fail/Pass ohne PingCastle, Review bei gemischten/unlesbaren Varianten oder widerspruechlichen Eintraegen derselben Baseline-GPO, Not configured nur mit Abwesenheitsevidenz. Bestehende Rollenwahl bleibt explizit; ADMX-/wertunabhaengige Baseline-Metadatenluecken bleiben sichtbar. Work, Guidance, Result DB und JSONL behalten alle Varianten ohne synthetischen Gesamt-Sollwert; alte Runs 1.3.3/1.4.0 werden nicht umgeschrieben. Vertrag in GPO_BASELINE_EVALUATION.md und Re-evaluation-Abgrenzung in REIMPORT_POLICY.md. Synthetische Gesamtsuite: 1.250 bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung erfolgreich. Fokussierte Regressionen pruefen gespeicherte Evidenz, Kontenlisten, Typen, Metadaten, Historie, Work-Filter und gerenderte Result-DB-Details. Client-/Server-Produktionsbuild erfolgreich; bestehende Bundlegroessenwarnung bleibt. Keine Realdateninspektion, Datenmigration, Neuimport oder automatische Bestandsneuauswertung; laufendes Backend nicht neu gestartet. Phasenstatus unveraendert.
Implementiert 2026-09-11 (Phase 8, Timeslotvergleich P0/P1): P0-Audit fuer Scanner/Format/GPO abgeschlossen und additiven P1-Domaenenvertrag 1.0.0 begonnen. Identitaet verlangt deklarierte Granularitaet, Scope und bei Objektvergleich Typ/Namespace/Schluessel; technische Behebung verlangt gleiche Identitaet, kompatible Verarbeitung und neuere belastbare Evidenz. Ungeklaerte Zwischenstaende erhalten Verpflichtungsherkunft, auditierter Scope-Ausstieg wird separat validiert. Sechs synthetische Domainregressionen, Local-only-/Designcheck und beide Builds erfolgreich. Gesamtlauf 1.224 bestanden, acht optionale DB-Tests uebersprungen; ein Node-Importtest lief unter Gesamtlast ueber sein unveraendertes 5-Sekunden-Limit und bestand isoliert 5/5 in 1.193 ms. Keine Anbindung an laufende Engine/Persistenz/API/UI, keine Importversion, Reimporte oder Datenmutation. P1 bleibt in Arbeit, P2-P6 offen.
Geplant 2026-09-11 (Phase 8, ausdruecklicher Planauftrag): Detailplan fuer belastbare A/B-Vergleiche mit sieben offenen Umsetzungspaketen P0-P6, fachlichen Entscheidungsregeln, Reimport-/Neuberechnungsgrenze, Risiken und synthetischer Abnahmematrix angelegt. Phasenuebersicht und aktuelle Etappe unterscheiden historischen Abschluss von offener Erweiterung; Vergleichs- und Scoringdokumentation erhalten datierte Statushinweise. Neue lokale Dokumentationsverweise, Hauptplananbindung, Paketvollstaendigkeit und Patchformat geprueft. Keine Implementierung, Migration, Reimporte oder Laufzeittests; npm test/build wegen ausschliesslicher Dokumentationsaenderung nicht erneut ausgefuehrt.
Verifiziert 2026-09-11 (Phase 7, Client-Info-Risikoregister): Zweigeteilte Accepted-Risks-Kachel unter den bestehenden Client-Informationen implementiert. Linke Auswahl zeigt Quelle, Zustand und Wiedervorlage; rechte Ansicht zeigt den vollstaendigen aktuellen Entscheidungsinhalt und alle Versionen. Kanonische Vorkommnisse werden blattuebergreifend dedupliziert, schmale Ansichten stapeln ohne Textverlust. Zwei neue Registertests und 18 fokussierte Tests erfolgreich; Gesamtlauf 1.218 bestanden und acht optionale DB-Tests uebersprungen, ein paralleler Fuenf-Sekunden-Ausreisser bestand isoliert in 4.971 ms. Local-only-/Designchecks, beide Builds und synthetische Browserpruefung erfolgreich. Keine API-, Persistenz-, Import-, Score-, Reimport- oder Datenmutation.
Verifiziert 2026-09-10 (Phase 7/9-11/13, Accepted Risk): Findingheader-Button, vollstaendiger Entscheidungsdialog, Entwurf/Annahme/Widerruf und versionierte Review-JSONB-Ablage ueber geschuetzte API implementiert. Best-Practice-Ausgangspunkt gemaess Nutzerantwort, getrennte Freigabedokumentation und verbindliche Fristen; keine behauptete Signaturpruefung. Reimport erhaelt Originalentscheidungen mit erneuter Pruefung fuer den Ersatzbefund. 1.214 Tests, Local-only-/Designchecks, beide Builds und synthetische Browserpruefung erfolgreich; acht optionale DB-Integrationstests uebersprungen. Frontend/Backend HTTP 200. Keine Migration oder Realdatenmutation; Firmenfreigaben und unabhaengige Aufbewahrung bleiben in Phase 14 offen.
Verifiziert 2026-09-10 (Phase 7/13C, Purple-Knight-Scorezuordnung): Separate native Summary und eindeutige Findings desselben Scanlaufs werden sicher verbunden. Regression 65 native/96 Passed/19 Deviations ergibt 65 Native score in der Dashboardprojektion. 1.199 Tests, Sicherheits-/Designchecks und beide Produktionsbuilds erfolgreich. Keine Datenmutation oder Realdateninspektion.
Verifiziert 2026-09-10 (Phase 6/13C/7, weitere Scanner): Native-first-Regel in verpflichtende Profildefinitionen und gemeinsamen Katalogpfad ueberfuehrt. Zehn neue Regressionen; 1.198 Tests, Sicherheits-/Designchecks und beide Produktionsbuilds erfolgreich. Keine Migration, Reimportmutation oder Backendneustart.
@ -2228,6 +2580,12 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung |
| 2026-09-16 | Phase 7/13B: Registry.pol-Zustandsregeln persistent verankert und mit Evaluator 1.9.0 umgesetzt. Delete-value/Delete-all/Create-key und typisierte Leerwerte sind vergleichbar; Schluesselziele beziehen konfigurierte Kindwerte samt Evidenzreferenzen ein. Gewoehnliche fehlende Empfehlungen bleiben Not configured, waehrend fehlende ausdruecklich verlangte Schluessel- oder Leerwertpraesenz Fail ist. Historie bis 1.8.0 bleibt unveraendert und wird nur ausdruecklich neu ausgewertet. 32 fokussierte Tests und exakte Work-Statusregressionen, 1.280 bestandene Gesamttests, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Kein Reimport, Processing-Manifest- oder SQL-Schemawechsel; keine reale Bestandsneuauswertung. |
| 2026-09-15 | Phase 7: Work verwendet beim normalen Einstieg und bei neu initialisierten Projekt-, Timeslot-, Blatt-, Import- und Reset-Zustaenden immer `Deviation`. Die bisherige automatische GPO-Ausnahme `All` ist durch die ausdrueckliche Nutzerentscheidung ersetzt. Bereits gesetzte Filter aus Client Info, Pathfinder und anderen Drilldowns bleiben beim Wechsel nach Work erhalten; explizit gesetztes `All` bleibt damit ebenfalls moeglich. Permanente synthetische Navigations- und GPO-Regressionen, 1.270 bestandene Gesamttests, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Daten-, Verarbeitungs- oder Reimportaenderung. |
| 2026-09-15 | Phase 7/13B: Irrefuehrende manuelle Aufforderung am partiellen GPO-Import entfernt. Der gelbe technische Zustand lautet wieder `Limited analysis`; er ist keine Aufgabe, kein automatisches Manage-Signal und keine Aufforderung zur Einzelvalidierung. Damit wird die bindende Produktentscheidung vom 2026-09-06 wiederhergestellt und die zwischenzeitliche Formulierung `Partial · review required` ausdruecklich ersetzt. Synthetischer Rendergegenfall, 17 fokussierte Tests, 1.268 bestandene Gesamttests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich; acht optionale PostgreSQL-Tests uebersprungen. Keine Daten- oder Verarbeitungssemantikaenderung. |
| 2026-09-15 | Phase 7/13B: GPO Analysis verwendet fuer seine Baseline-Ergebniszaehler jetzt dieselben backendautoritativen aktuellen Aggregationslaeufe wie Work und Client Info. Historische Aggregationen bleiben in der Evaluationshistorie, werden aber nicht mehr zu aktuellen Pass-/Fail-/Not-configured-Zaehlern addiert. Review required ist in der Zusammenfassung sichtbar. Exakte synthetische Mehrlauf-/Ein-Timeslot-Komponentenregression, 26 fokussierte Tests, 1.267 bestandene Gesamttests plus isoliert bestandener bekannter Last-Timeout und beide Produktionsbuilds erfolgreich; acht optionale PostgreSQL-Tests uebersprungen. Keine Datenmutation, Neuauswertung, Import-, Persistenz-, Schema-, Manifest- oder Reimportaenderung. |
| 2026-09-15 | Phase 13/9-11: Timeslot-Importlast korrigiert. Inaktive GPO-Importsteuerelemente in Timeslotzeilen erzeugen keine eigenen Poller mehr; sichtbare Statusansichten und der Data Hub fragen nur waehrend queued/running ab, bewahren unveraenderte Zustandsobjekte und berechnen Result-Facetten nur bei einer echten Statusrevision neu. Datei- und Definitionszuordnung im PostgreSQL-Commit ist je Import vorindiziert. Gemessener Ausgangszustand: 4,4 s Vorschau, 200,3 s Commit unter 270 Facetten- und 326 Statusabfragen in fuenf Minuten. Synthetische Interaktionsregression, 30 fokussierte Tests, 1.266 bestandene Gesamttests plus isoliert bestandener bekannter Last-Timeout, Local-only-/Designpruefung und beide Builds erfolgreich; acht optionale PostgreSQL-Integrationstests uebersprungen. Keine Schema-, Daten-, Importsemantik-, Manifest- oder Reimportaenderung. Restrisiko serieller PostgreSQL-Einzelwrites dokumentiert. |
| 2026-09-14 | Phase 7/13B: Regression im Work-Filterkontext korrigiert. `Source: Scan = GPOs` reduziert auf dem All-Records-Blatt nun vor Priorisierung und Facettenlimit auf den GPO-Vertrag; das Statusmenue wird unabhaengig von einem separaten GPO-Blatt mit All, Deviation, Passed, Not configured, Conflicts und Duplicates vollstaendig erzeugt, fachliche Nullwerte bleiben waehlbar. GPO-Statuswerte werden ausschliesslich gegen das aktuelle Statusfeld geprueft; ein Pass-Vergleichswert kann einen Not-configured-Datensatz nicht mehr als Passed filtern. Fremde Scannerfacetten und ungueltige gesetzte Altfilter werden entfernt, `Source: Scan` bleibt vollstaendig und rechts; ohne Quellrestriktion kehrt der Mischkontext zurueck. Exakte synthetische Interaktionsregressionen pruefen Anzeige, tatsaechliche Filterwirkung und den gemeldeten Gegenfall; 51 fokussierte Tests sowie der Gesamtlauf mit 1.243 bestandenen und acht uebersprungenen Tests, Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. `AGENTS.md` bindet dokumentierte Nutzerentscheidungen als Abnahmekriterien und verlangt transparente Meldung vor regelwidrigen Aenderungen. Keine Daten-, Import-, API-, Persistenz-, Score- oder Reimportaenderung; ausschliesslich synthetische Inhaltspruefung. |
| 2026-09-08 | Phase 7: Verbindungskurven der Client Info treffen ihre Domainknoten wieder. Feste Zielkoordinaten in einem verzerrten SVG-Koordinatensystem durch eine aus dem Layout gemessene Geometrie ersetzt, Neumessung ueber Groessenbeobachter und Fensterereignis. Am Dummy bei drei Breiten geprueft: Abweichung 0 Pixel statt bis zu 80. Keine Aenderung an Werten oder Kachelgeometrie. 1.011 Tests, Local-only-/Designpruefung und Builds erfolgreich. |
| 2026-09-08 | Phase 7: Aufbauzeit der Client Info von ueber vier Sekunden auf unter 100 Millisekunden gebracht. Ableitung je Datensatz einmal statt je Rendern, ein Durchlauf statt vierzehn Punkteberechnungen, Domainzuordnung mit einmaligem Klassifikationstext, schwache Verweise je Datensatz sowie ansichtsuebergreifender Zwischenspeicher fuer Facetten und Auswertung; Herkunftswerte je Datensatzliste einmal gezaehlt. Gemessen am freigegebenen Dummy mit 8.257 Datensaetzen: Ableitung 3.123 ms -> 0 ms, laengste blockierende Aufgabe > 4.000 ms -> < 100 ms. Keine Aenderung an Werten, Zaehlungen, Darstellung oder der geteilten Statuslogik. 1.007 Tests, Local-only-/Designpruefung und Builds erfolgreich. |
| 2026-09-08 | Phase 7/13C: Dummy-Reimport abgeschlossen. Purple-Knight-Scan nach Backendneustart ueber den Importdialog ersetzt; alle aktiven Scans tragen den aktuellen Verarbeitungsstand (Purple Knight 1.2.0, CIS-CAT 1.4.0, PingCastle 1.4.0, Maester 1.0.0), Datahub meldet durchgehend `Ready`. Befunde und Umgebungskontext unveraendert; keine Code-, Schema- oder Persistenzaenderung. |

View File

@ -15,7 +15,35 @@ A processing-profile version must be added and existing affected imports must be
Presentation-only changes, labels, layout, filtering over already retained fields, and score-formula changes over already retained inputs do not require a source reimport. They must not be disguised as processing changes.
Every scanner profile, including future profiles, declares its current processing contract. Historical contracts remain append-only. A material import change increments the relevant parser, extractor, profile, mapping, normalization, or presentation version and records `reimportRequired` plus the affected dimensions in `PROCESSING_VERSION_MANIFESTS`. Data Hub derives its warning from that persisted contract. A generic fallback remains visibly review-required until a scanner-specific profile can interpret the source.
Every scanner profile, including future profiles, declares its current processing contract. Historical contracts remain append-only. A material import change increments the relevant parser, extractor, profile, mapping, normalization, or presentation version and records `reimportRequired` plus the affected dimensions in `PROCESSING_VERSION_MANIFESTS`. Data Hub derives its warning from that persisted contract.
Clarification 2026-09-11: Data Hub assesses processing readiness per stored scan run. A generic-only run remains review-required. Generic companion files alongside exactly one recognized scanner profile in the same run do not independently require reimport when all file processing contracts are current or compatible. Every file retains its own profile, provenance and mapping; an inherited display label is not native recognition. A different run cannot supply the missing scanner context. Unknown/incompatible contracts, actual profile updates and unresolved client-review transfers remain actionable. A review-only state does not offer repeated reimport with unchanged processing as a remedy. This corrects status presentation over existing facts and requires no reimport or processing-version change.
## GPO re-evaluation and presentation
Clarification 2026-09-17 (missing retained GPO source): A persisted `backend-controlled` storage reference records custody and provenance; it does not prove that the file is currently accessible. The GPO status endpoint checks availability on each read, independently of repository revision, and distinguishes `Upload required` for missing bytes from `Storage unavailable` for an access/configuration failure. Automatic job repair, Retry and Reprocess check availability before enqueueing. A queued job that encounters a missing file stops after its first failed attempt instead of retrying three times. Data Hub and the GPO processing panel offer an artifact-bound original ZIP/folder upload; the project, optional timeslot, exact byte size and SHA-256 must match. Restoration atomically republishes only missing bytes under the original storage key, never overwrites an existing object and preserves original identities, timestamps and retained analysis history. Ordinary byte-identical resubmission repairs missing bytes as well. A missing-source job may resume after restoration; an already current analysis does not need another run just to restore its original file. Evaluation-only updates remain possible from retained inputs, while an analyzer update still requires the source before reprocessing. This is storage recovery, not a changed interpretation: no parser/analyzer/processing-manifest version bump, SQL migration or bulk reprocessing is required.
Clarification 2026-09-14 (baseline variants): Evaluator 1.5.0 compares each retained candidate independently and requires unanimous supported outcomes for Pass/Fail. It stores the versioned per-variant assessment and original values in the existing result JSON/JSONB. Historical 1.3.3/1.4.0 runs remain unchanged and require explicit re-evaluation through Data Hub; no scanner processing-manifest change or source reimport is required for this evaluation-only change. Inventory context does not establish a GPO target role. See [GPO_BASELINE_EVALUATION.md](GPO_BASELINE_EVALUATION.md) for the decision and evidence contract.
Clarification 2026-09-15 (mechanism-scoped baseline partiality): Evaluator 1.6.0 no longer lets a parser diagnostic from an unrelated delivery mechanism invalidate a separately resolved comparison. Diagnostics applicable to the candidate mechanism, unexplained partiality, ambiguous values and incomplete customer evidence remain review-only. Historical 1.5.0 results remain unchanged and require explicit re-evaluation in Data Hub; this evaluation-only change does not require a source reimport or processing-manifest update.
Clarification 2026-09-16 (policy-setting conflict roll-up): Evaluator 1.7.0 compares each retained configured member value of a customer conflict independently. One valid failing member makes the technical policy-setting verdict Fail once, while conflict/duplicate remain separate diagnostic flags; unresolved evidence remains review-only when no independently valid fail exists. Historical results through 1.6.0 remain unchanged and require explicit re-evaluation in Data Hub. The inputs were already retained, so this evaluation/presentation change requires neither a source reimport nor a processing-manifest update.
Clarification 2026-09-16 (analyzed-scope absence): Evaluator 1.8.0 reports an applicable recommendation with no observed configured value in the analyzed backup set as score-neutral `Not configured`, including for selected-subset imports. It retains a scope-limit diagnostic and does not claim domain-wide or effective-device absence. Historical results through 1.7.0 remain unchanged and require explicit re-evaluation in Data Hub; retained inputs are sufficient, so no source reimport or processing-manifest update is required.
Clarification 2026-09-16 (Registry.pol state requirements): Evaluator 1.9.0 compares retained `delete-value`, `delete-all-values`, `create-key` and typed empty-string recommendations as explicit backup-configuration states. Historical results through 1.8.0 remain unchanged and require explicit re-evaluation in Data Hub. The required original Registry.pol instructions, types and values are already retained, so this evaluator-only change requires no source reimport, processing-manifest update or SQL migration.
Clarification 2026-09-17 (baseline analyses of the written form): The Microsoft baseline analyzer consumes the same Security Template parser, so a baseline analysis produced before the parser change still carries the case-folded section. An evaluated setting takes its displayed identity from the baseline side, so the baseline packages must be re-analysed for the written form to reach Work. Append-only analyzer version `microsoft-baseline-gpo-settings` 1.3.0 -> 1.4.0; the accepted version list keeps every earlier value so historical analyses stay readable and are not rewritten. Two further selection rules were corrected in the same step. First, only analyses of the very same baseline package are one lineage: an interrupted supersession chain may leave several of its generations unsuperseded, and the newest analyzer version then wins, while independently analysed packages remain independent benchmarks. Second, the current run per aggregation is chosen by the distinct baselines actually compared rather than by the number of analysis references, so duplicated generations no longer outrank a narrower current run; where retained results name no baseline, the declared references stay the only measure. No source reimport, SQL migration or evaluator change; re-analysing a retained baseline package and re-evaluating is sufficient.
Clarification 2026-09-17 (written security-template section and key): The Security Template parser folded the section name to lower case and discarded the written form, so `[Privilege Rights]` was retained only as `privilege rights`. The parser now keeps the written section and key; the case-folded forms stay internal to grammar selection, grouping and the identity digest, which keeps existing observation and identity IDs stable. The native target resolver stores the written section and key in the retained target, matching what the registry resolver already did, so Work shows `Privilege Rights - SeCreateTokenPrivilege` and the value stays searchable against Microsoft documentation and external tables. This changes persisted parsing output, so it is an append-only processing-version change, not a presentation change: `gpo-security-template-inf` 1.1.0 -> 1.2.0, `gpo-backup-policy-analysis` 2.2.3 -> 2.2.4 and the GPO job processor 2.2.3 -> 2.2.4. Historical imports keep their retained values unchanged and are marked for reprocessing: Data Hub reports an affected GPO backup as `Reprocessing required` with the analyzer versions and offers the existing reprocess action on the stored source, so no upload is needed. Reprocessing precedes re-evaluation, and prior runs remain retained as history. No SQL migration and no evaluator change.
Clarification 2026-09-17 (Not comparable verdict): A retained customer setting whose delivery mechanism has no comparable technical policy identity - today always a Group Policy Preference - reaches Work under the score-neutral verdict `Not comparable` instead of being dropped. It is never resolved into a policy identity and never compared with a Microsoft baseline. The unified read projection additionally exposes the already retained preference CSE, action and item-level targeting status so that context stays readable in the agreed GPO columns; no new column, facet or persisted field is introduced. Snapshot-aggregation review hints that restate an unresolved identity already reported by their source analysis are no longer emitted a second time. All of this projects facts the importer already retained, so no processing-manifest version, source reimport, evaluator change or SQL migration is required.
Clarification 2026-09-17 (No baseline rule verdict): Work now projects every retained customer policy state under exactly one verdict. A resolved technical identity with no applicable Microsoft baseline recommendation in the compared baseline set receives the score-neutral verdict `No baseline rule`; a retained comparison that yields no safe verdict stays `Review required` instead of being dropped. `Conflicts`/`Duplicates` remain diagnostic flags on the setting's own row and no longer replace its verdict, so their counts and the Result-DB consistency projection follow the flag rather than the row status. Observations that already evidence an evaluated comparison, including child values of a registry-key requirement, are never reported a second time; unresolved identities keep their separate analysis-gap reporting. This is a read-time projection over already retained observations, aggregations and evaluations: no evaluator version change, source reimport, processing-manifest update or SQL migration is required, and no persisted record is rewritten.
Clarification 2026-09-17 (current baseline analysis generation): GPO evaluation readiness is no longer derived from the evaluator version alone. A retained evaluation stays pinned to the baseline analysis ids it was created with, so a newer Microsoft baseline analysis generation of the same baseline also marks it as requiring re-evaluation. The Re-evaluate action resolves every retained baseline reference to its current non-superseded analysis and never widens the evaluated baseline set: an unknown reference and a baseline without a newer generation keep their own analysis id. Prior runs remain retained and are not rewritten. Baseline packages, their analyses and the customer inputs are already persisted, so this readiness/selection change requires no source reimport, processing-manifest update or SQL migration.
Clarification 2026-09-11 (GPO evaluation readiness): A saved baseline comparison from a previous evaluation engine requires re-evaluation of retained inputs, not automatically another upload. Data Hub reports this separately from scanner-profile reimport, scoped through active profile, snapshot, identity-analysis and artifact references. Its explicit Re-evaluate action uses the existing evaluation API; prior runs remain retained. Work labels readable previous-engine comparisons as historical and keeps their original outcomes in a separate Historical result field instead of presenting them as current Pass/Fail. Missing baseline description metadata and failed description loading are separate presentation states. OS grouping and row-bound native counts use already retained environment observations; these display changes introduce no processing manifest or source reimport requirement.
## Preservation contract
@ -30,7 +58,8 @@ Reimport replaces scanner-derived data only. The following client-owned values a
The match uses scanner identity, native rule ID, and affected asset. It succeeds only for one old and one new occurrence. A stable definition-level review remains attached without copying. ASSESSOR never falls back to title-only or positional matching.
Structured Accepted Risk decisions retain every original revision and its target occurrence ID. A transferred acceptance displays `Review required` when the new occurrence differs from the approved target. Preservation does not silently renew approval, expiry or scope: recording acceptance for the replacement requires a new decision. See [RISK_ACCEPTANCE.md](RISK_ACCEPTANCE.md).
When a match is absent or ambiguous, the old review remains on the retained previous scan. The replacement records an unresolved count and Data Hub shows a persistent review-required state. The reimport is still auditable and does not silently discard or guess customer data.
Both direct reimport and adoption of an already imported byte-identical replacement execute review transfer and scan supersession in the same repository transaction. The exact source SHA-256 multiset, assessment, and scanner must match before replacement is accepted.

545
docs/REPORT_STRATEGY.md Normal file
View File

@ -0,0 +1,545 @@
# Assessment output strategy
Decision: 2026-09-18, explicitly approved by the user. No implementation phase is
assigned yet; these outputs are a new deliverable that is not covered by an
existing plan phase. This document fixes *what is shown* before any renderer is
written.
ASSESSOR produces **three** artifacts from one assessment:
| Artifact | Audience | Nature |
| --- | --- | --- |
| A — Management Overview (HTML) | customer decision makers | frozen snapshot |
| B — Technical Report (HTML) | engineers reproducing a finding | frozen snapshot, complete |
| C — Arbeitsgrundlage (XLSX + HTML) | the people doing the work | living document, curated |
| D — Delta Report (HTML, management + technical cut) | both, after a rescan | frozen snapshot of the change |
The dividing principle: **a report states the finding and its proof; the
Arbeitsgrundlage states the measure and who owns it.**
Remediation progress is proven by rescans, never by self-reported status. See
*Progress is proven by rescan* below.
## Approved delivery decisions
| Decision | Choice |
| --- | --- |
| Delivery | Separate files per artifact |
| Design | ASSESSOR application look |
| Technical scope (B) | Deviations, diagnostics and score-neutral classes |
| Interaction | Static, print- and PDF-ready, no JavaScript |
| Language | German, all artifacts |
| Arbeitsgrundlage format | XLSX to work in, HTML as the frozen status view |
| Arbeitsgrundlage scope | Curated selection, plus the complete list on a second sheet |
| Progress evidence | Rescan only; planning fields stay but are marked unverified |
| Delta delivery | Own artifact, management and technical cut |
Separate files keep the confidentiality boundary real: the Management file can be
forwarded without carrying registry keys, SIDs, evidence paths or parser
versions in its source. A single file with a hidden section would still contain
them.
## Report language
Both reports are written in German: headings, prose, labels, table headers,
legends and every explanatory sentence.
This does not change the Client Info dashboard, which remains English-only by its
own decision. The reports are separate customer-facing artifacts; the two rules
stand side by side and neither overrides the other.
German is the carrier language. **Established English technical terminology stays
English** and is not translated, not hyphenated into a German compound and not
replaced by an invented German equivalent. This is the general rule, not a list
of exceptions. It covers at least:
- the agreed GPO verdicts `Deviation`, `Passed`, `Not configured`,
`No baseline rule`, `Not comparable`, `Conflicts`, `Duplicates`
- the security domain names from `src/features/dashboard/security-domains.ts`
- assessment vocabulary such as Finding, Baseline, Score, Scope, Severity,
Pass Rate, Review, Owner, Due Date, Remediation, Evidence
- platform vocabulary such as Group Policy, Registry, Domain Controller,
Privilege, Attack Path, Hardening
Reason: these are the terms the readers already use, and most of them appear
verbatim in the application's Work filters and in the Client Info dashboard. A
reader must be able to match a line in the report to the view it came from, and a
translated second vocabulary would weaken the documented agreements.
Where a term is not self-explanatory to a management reader, it is introduced once
with a short German explanation and then used in its canonical English form.
German prose carries the sentence; the term inside it stays English.
Original source values, registry keys, value names, principals, evidence paths and
native identifiers are never translated or localized. Numbers and dates follow
German formatting.
The shared projection stays language-neutral and carries data, not display
strings. All German wording lives in the renderers.
## Shared report projection
Implemented in `src/domain/report-projection.ts`
(`REPORT_PROJECTION_VERSION = '1.0.0'`), covered by
`tests/domain/report-projection.test.ts`.
All artifacts read **one** projection built in the platform-neutral domain layer.
The projection is the single source of truth; the renderers only choose depth,
never content. Two consequences are binding:
- A number that appears in both reports is the same number. The Management
Overview never rounds, re-derives or re-filters a value that the Technical
Report states differently.
- Renderers contain no source-specific parsing, no scoring arithmetic and no
database access. They receive the finished projection.
The projection is built on export only. It is not part of the workspace load
path and must not derive presentation fields for records no report section
consumes.
## Report A — Management Overview
Implemented in `src/exports/management-overview-report.ts`
(`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by
`tests/exports/management-overview-report.test.ts`.
Answers: how do we stand, what is most urgent, what is this based on, what do we
not know. Its reading order follows the agreed Client Info grammar.
**Named deviation from the `assessor-ui` skill:** a delivered document uses no
application shell — no sidebar, topbar, command band or context strip — and none
of the four page archetypes, because it is read and printed outside the
application. Typography roles, donut geometry, tile and segment grammar, status
colour rules and the panel inset apply unchanged. Light is the document default
because a report is printed on white; dark renders from the same semantic roles
through `prefers-color-scheme`.
The bundled Inter is referenced by family name with a system fallback. No font,
style, script or image is loaded when the file is opened.
### 1. Assessment identity
Customer, environment (domain, forest, domain controller count from the
environment inventory), assessment period, report date, ASSESSOR product version.
### 2. Overall posture
The single score as a donut using the shared donut geometry, with its method
label. Binding wording from `SCORING_MODEL.md`:
- The GPO contribution is *conformance of configured, comparable in-scope
settings*. Binding German wording: „Konformität der konfigurierten,
vergleichbaren Einstellungen im Scope". It is never labelled baseline
compliance, baseline coverage or proof that recommended settings are
implemented — in German neither „Baseline-Compliance", „Baseline-Abdeckung"
nor any phrasing implying the baseline is fully rolled out.
- PingCastle enters as `100 − native risk`; the raw risk stays secondary context.
- A mixed-method index is named as such. Security Domain values are pass rates
and are not a decomposition of the index.
### 3. Security Domains
The eight domains from `src/features/dashboard/security-domains.ts` as a
connected pass-rate spine beside the overall score. Not a table.
### 4. Priority Findings
Grouped by security domain. **There is no global cross-tool ranking.** Inside a
domain, findings are ordered by the declared priority basis of their own source,
and that basis is named at the point of display.
Per entry: title, domain, the source's own rank value with its basis, one
sentence of impact, plus owner and status when a review exists.
GPO deviations form their own equal-weight block with their count, the affected
policy settings and the scope limit. They are never assigned an invented
criticality.
No registry keys, no SIDs, no file paths, no raw values.
See *Priority without a common severity* below for why.
### 5. Remediation status
Open, in progress, accepted, overdue, and findings without an owner — derived
from the stored reviews including accepted-risk data. If no reviews are
maintained, the section is omitted rather than rendered empty.
### 6. Coverage and gaps
What was assessed, what was not, unassessed controls, and every scope limit that
bounds a statement. A GPO import covering a subset of the domain is reported as
scope-limited and never presented as a domain-wide or effective-device claim.
This section is what makes the report defensible.
### 7. Source overview
Per tool: run or file date, score contribution, and the method that produced it.
### Excluded from Report A
Per-record tables, raw values, evidence paths, diagnostics, parser, analyzer and
manifest versions.
## Report B — Technical Report
Answers: what exactly is configured, what should it be, where does that stand,
how does ASSESSOR know, what do I do.
### 1. Scope and provenance
Imported artifacts, processing, analyzer and manifest versions, reimport status,
aggregation and evaluation run identifiers. Enough to reproduce the result.
### 2. Result inventory
Totals per status, severity, domain and tool, including the score-neutral
classes. Passed results appear here as a count.
### 3. Findings in detail
Grouped by security domain, ordered by severity inside each group. Per finding:
- technical identity and scope
- actual against expected value, with the field-specific deviation marking used
by the Finding Reader: `Operation`, `Data` and `Type code` for registry policy
differences, several fields only where each is independently proven
- principal sets compared per member: only configured principals absent from
every retained baseline variant are marked; a principal also present in the
expected baseline stays neutral
- GPO source attribution as vertically stacked sections per exact backup
identity, each with evidence reference, source-local sequence and every
original instruction immediately visible
- conflict and duplicate flags, kept separate from the policy verdict
- baseline variants where more than one exists
- the remediation step from the review
### 4. GPO policy register
All seven verdicts in full: `Deviation`, `Passed`, `Not configured`,
`No baseline rule`, `Not comparable`, `Conflicts`, `Duplicates`. The
score-neutral verdicts are labelled as score-neutral at the point of display, so
that a reader cannot mistake `Not configured` for a remediation obligation.
### 5. Diagnostics
Unresolved identities, parser gaps, scope limits, declared-against-counted
mismatches and incomplete backups. Each observation appears once with its
authoritative resolver reason and is not restated by snapshot aggregation.
### 6. Appendix
Complete result index.
## Artifact C — Arbeitsgrundlage
Decision: 2026-09-18, explicitly approved by the user.
Answers: what do we do, who owns it, by when, where does it stand.
The measured volume makes this its own artifact: the stored data holds **1,806
actual deviations** (CIS-CAT 969, Maester 350, PingCastle 277, Purple Knight 210)
plus the GPO deviations. Rendering each of those with full technical depth inside
Report B would produce a document nobody works with.
Size is not the decisive reason, though. A report is a frozen snapshot that
proves what was found on a date and is archived unchanged. An Arbeitsgrundlage is
a living document that the customer edits for weeks: owner, due date, status,
comment. Forcing both into one file makes both worse.
### Data model
The Arbeitsgrundlage is the stored review model in `src/domain/review.ts`:
`status`, `priority`, `owner`, `due`, `prerequisites`, `access`, `nextStep`,
`evidence`, `comment`, the markers `quickwin`, `blocked`, `external`, `question`
and `notRelevant`, plus `riskAcceptance`.
`include` is the curation switch. The consultant selects what reaches the
customer as a work item. Shipping 1,806 unfiltered items produces no action at
all.
### Structure
- **Sheet 1 — Maßnahmen.** Only the curated selection, with a stated selection
criterion. One row per work item.
- **Sheet 2 — Vollständige Abweichungen.** All deviations for reference, so the
customer can pull further items in without the working list overflowing.
The HTML variant renders the same content as a frozen status view for status
meetings and filing. It is generated from the same projection, never maintained
separately.
### No duplicated technical depth
A work item carries its stable finding identifier, the measure, prerequisites,
required access and the owner — not the evidence, not the source attribution, not
the baseline variants. Those stay in Report B and are reached through the
identifier.
One fact, one place. Repeating the technical detail would leave two diverging
versions after the first correction.
## Progress is proven by rescan
Decision: 2026-09-18, explicitly approved by the user: *"Den Fortschritt
dokumentiere ich durch Rescans — für mich zählt nur der technische Beweis."*
Remediation progress is established by comparing scan runs, never by a status
somebody ticked. This follows `TIMESLOT_COMPARISON_PLAN.md`, which already
replaced the conclusion *absent equals remediated*.
### Binding rules
- No artifact presents a self-reported status as a resolved finding. A work item
marked done in the Arbeitsgrundlage stays an open deviation in every report
until a rescan proves otherwise.
- A delta uses the lifecycle vocabulary of the timeslot contract — demonstrably
remediated, still open, unresolved, not comparable — never a binary
done/open. *Unresolved* is a legitimate and frequent outcome and is shown as
such.
- Absence of a finding is not proof of remediation. Remediation is claimed only
where the profile-bound negative-proof contract is satisfied for exactly that
rule and object.
- Result presence and deviation lifecycle stay separate. A rule can still be
reported while its concrete deviation is fixed.
- No effective-endpoint claim is derived from a backup-configuration comparison.
### Why the caveat is not theoretical
The stored data shows how easily absence would be misread: Maester carries **845
`not_run`** and 10 `execution_error`, Purple Knight 34 `not_applicable` and 10
`not_run`. A check that did not run would otherwise turn into a success message —
the most dangerous false statement a progress report can make, because it errs in
the comfortable direction.
### Artifact D — Delta Report
An own artifact per rescan, in the same two cuts as the reports. The initial
report stays frozen and is never rewritten; the change is a separate document
with its own reporting date.
**The comparison is against a chosen reference run, and that choice is made in
ASSESSOR — not at export time.** The export renders a comparison that already
exists; it contains no run-selection logic, no fallback to "the previous run" and
no implicit pairing of its own. Consequences:
- The chosen reference run is part of the stored comparison, together with its
contract version, engine versions and input fingerprint. The export reads it
and never re-derives it.
- The document names both compared runs explicitly — reference and current, each
with its identity and date — so it stays self-describing once it is out of the
application.
- Without a stored comparison there is no Delta Report. The export is
unavailable rather than guessing a pairing.
This keeps comparison a domain concern and presentation a separate one: the Delta
Report is a renderer, not a second comparison engine.
- **Management cut:** score movement, what is demonstrably remediated, what
persists, what is newly found, and how much of the comparison is unresolved or
not comparable. The unresolved share is stated, not hidden.
- **Technical cut:** per finding the lifecycle verdict with its evidence — which
run, which identity match, which contract qualified it — plus the
disqualifying diagnostics where a comparison was not admissible.
### Planning fields in the Arbeitsgrundlage
`status`, `due`, `owner` and the markers stay in the XLSX as the customer's own
steering aid. They are labelled as unverified self-report at the point of display.
ASSESSOR never promotes them into a report as a result.
The export therefore stays one-way by design, not by limitation: progress does not
need a return channel because it comes from the next scan.
## Priority without a common severity
Decision: 2026-09-18, explicitly approved by the user.
Severity is not a property every scanner delivers. Measured against the stored
data:
| Tool | Results | Severity coverage | Native ranking signal |
| --- | --- | --- | --- |
| CIS-CAT | 1,233 | none, all `unknown` | none |
| PingCastle | 277 | derived from Points | Points 0–50+ |
| Maester | 1,590 | 99.4 % | native severity |
| Purple Knight | 1,309 | 100 %, 187 `critical` | native severity |
| GPO / baseline | — | none | none |
A severity-ranked global list would therefore drop CIS-CAT and GPO out of the
Priority Findings entirely, let Purple Knight dominate merely because it is the
only source that emits `critical` natively, and keep PingCastle permanently below
`critical` because `pingCastleSeverityFromPoints` caps at `high` by construction.
That ordering would describe the report formats, not the customer's risk.
### Binding rules
- Priority Findings are grouped by security domain. The eight domains are
tool-neutral and already exist.
- Within a domain, findings are ordered by the declared priority basis of their
own source. The basis is visible to the reader.
- No ranking is ever produced across sources with incomparable bases. A single
ordered list that mixes them is not permitted.
- GPO deviations are equal-weight. They get their own block with count, affected
policy settings and scope limit, and never an inferred criticality.
- A source without any defensible ranking basis lists its findings unranked and
says so. Unranked is an acceptable answer; an invented rank is not.
### Profile obligation
Implemented as `ToolPriorityBasisDefinition` in
`src/domain/tool-profile-definition.ts`, required on every `ToolProfileDefinition`
and enforced by `tests/profiles/declarative-profiles.test.ts`.
Analogous to the existing `securityScoring` obligation, every tool profile
declares a **priority basis**: the exact mapped properties, scale, direction and
semantics of the value used to order its findings — or an explicit reason why no
usable basis exists. Never infer a priority from an arbitrary field named
severity, level, score or rank.
A declaration may also list `excludedProperties`: fields that resemble a basis
but are not one, each with its reason. This is what stops a later change from
quietly promoting them.
| Profile | Declared basis |
| --- | --- |
| Purple Knight | `severity` — native criticality, the only source reaching `critical` without derivation |
| Maester | `severity` — native per test |
| PingCastle | `numeric` on Points, `0-50+`, higher-is-worse |
| CIS-CAT | `unavailable` — benchmarks state no per-recommendation criticality |
| Generic | `unavailable` — unrecognized fields establish no scale or direction |
PingCastle is declared on Points rather than severity on purpose: its canonical
severity is *derived* from Points by `pingcastle-severity-from-points` and is
capped at `high` by construction. Points keeps the granularity the capped
severity loses.
`reportPriorityBasisFrom` turns an `unavailable` declaration into an explicit
`equal-weight` basis carrying the profile's own reason, so a report states why a
source is unranked instead of implying the declaration is missing.
A derived, normalized cross-tool ASSESSOR priority is **not** approved. It would
require its own versioned rule and an explicit decision of its own.
### Guarded mis-mapping risk
`src/profiles/cis-cat/cis-cat-definition.ts` declares
`severityHeaders: /^(?:severity|level)$/i`. CIS `Level 1` / `Level 2` is a
hardening profile tier, not a criticality. It falls through to `unknown`, which
is harmless, and it is now recorded as an excluded property with its reason and
covered by a test. It must not be "corrected" into a severity, because that would
manufacture criticalities the benchmark never stated.
## Guardrails for both reports
- Score-neutral classes never appear in Priority Findings and never enter the
score. They remain fully inspectable in Report B.
- A GPO verdict is taken only from the authoritative rolled-up policy verdict and
the explicit conflict and duplicate flags. Never from comparison prose,
historical results, baseline text, guidance or evidence.
- Unknown or unavailable facts render as an em dash, never as an inferred zero.
- Both files are self-contained: inline styles, no CDN, no external fonts,
scripts or images, no network access when opened. A report leaves the building
and must not load anything.
- The bundled Inter variable font is embedded or a local system stack is used;
no font is downloaded at view time.
- Shared typography roles, donut geometry, marking semantics and panel inset
apply as in the application. Status colour stays in accent lines, markers,
arcs and score nodes, never as a full status fill.
- Static output: no JavaScript, printable to PDF without layout loss.
## Relationship to the existing export
### Current-view export — explicit user extension, 2026-09-18
The Work toolbar's CSV/MD/HTML buttons now export the current filtered and sorted
view, including Results, with exactly two content columns: `Soll-Zustand` and
`Abweichungen`. This supersedes their old marked-only selection. The user can
still restrict the view with `My Workspace`; that restriction combines with the
other active filters. A view export is a frozen, scoped inspection snapshot,
not the curated Arbeitsgrundlage C, a replacement for reports A/B, or a delta.
`src/exports/work-view-export.ts` supplies one click-time presentation projection
for these three formats, consuming existing Work records and the shared Results
resolver without ranking, scoring, re-evaluation or database queries. The shared
`report-projection.ts` contract for artifacts A–D remains unchanged. Native
remediation, baseline variants, exact source attribution and verification gaps
stay visible. Review completion never becomes a technical resolution. Long CSV
cells are preserved in labelled continuation rows (same finding ID), keeping two
columns and avoiding Excel's cell-size truncation. HTML is self-contained,
script-free, with locally bundled Inter embedded and responsive wrapping.
The following paragraph records the earlier strategy baseline; its description
of the toolbar's marked-only behaviour has been superseded by this extension.
`src/exports/dashboard-export.ts` and `ActionlistControls` already export the
review model as CSV, Markdown and a minimal HTML page. That HTML knows nothing of
the score, the security domains, GPO verdicts or provenance and uses Segoe UI
with fixed colours.
The existing action-list export is the seed of artifact C, not of the reports.
The reports are new; the action-list HTML is replaced by the artifact C renderer,
and the CSV path stays useful for ticket-system import.
## Open decisions
- How many findings each domain block shows before it is truncated, and how a tie
inside one source's ranking basis is broken.
- Whether the Microsoft baseline packages carry any per-setting classification
that could later give GPO deviations a defensible basis. Until proven, GPO
stays equal-weight.
- The selection criterion wording for the curated sheet, and whether it is fixed
or written per engagement.
- Which profiles can satisfy a negative-proof contract at all. Profiles that
cannot will never produce a *demonstrably remediated* verdict, and the Delta
Report must say so per source rather than leave the reader guessing.
- File naming convention across all artifacts, including how a delta names its
two compared runs.
## Dependency
Artifact D depends on the timeslot comparison actually producing stored results.
`assessor_timeslot_evidence_comparisons` exists with its contract version, engine
versions and input fingerprint, but currently holds no rows. Until the comparison
is implemented per `TIMESLOT_COMPARISON_PLAN.md`, artifacts A, B and C are
deliverable and D is not.
## Change log
- 2026-09-18: Strategy established. Delivery, design, technical scope and
interaction decided by the user; content inventory per report fixed.
- 2026-09-18: Report language decided by the user — German for both reports. The
agreed GPO verdict vocabulary and the security domain names stay canonical
English inside the German text; source values are never localized. The Client
Info English-only rule is unaffected.
- 2026-09-18: User correction — established English technical terminology stays
English as a general rule, not as an enumerated exception. German carries the
prose; technical terms are never translated or turned into German compounds.
- 2026-09-18: Priority model decided by the user after the user raised that not
every tool provides severity. Verified against stored data: CIS-CAT and GPO
have no severity at all, PingCastle cannot reach `critical` by construction.
Priority Findings are grouped by security domain with a per-source, declared
and visible ranking basis; no cross-tool ranking; GPO equal-weight in its own
block. Profiles must declare a priority basis. A normalized cross-tool
priority is explicitly not approved.
- 2026-09-18: Third artifact added on the user's proposal. The per-finding
working basis becomes its own deliverable — XLSX to work in plus an HTML status
view, curated through `include` with the complete deviation list on a second
sheet. Decisive reason is the differing lifecycle, not only the measured volume
of 1,806 deviations. Work items reference findings by identifier and never
repeat the technical depth of Report B.
- 2026-09-18: Progress model fixed by the user — progress is documented by
rescans, only technical proof counts. A fourth artifact is added: the Delta
Report, in a management and a technical cut, using the timeslot lifecycle
vocabulary. Self-reported status never becomes a reported result; planning
fields remain in the Arbeitsgrundlage marked as unverified. The earlier open
question about re-importing a maintained worklist is closed: the export is
one-way by design.
- 2026-09-18: Reference run decided by the user — a delta compares against a
chosen reference run, and the choice is made in ASSESSOR. The export renders a
stored comparison and carries no run-selection logic, no previous-run fallback
and no implicit pairing. Both runs are named in the document.
- 2026-09-18: Shared projection implemented as `src/domain/report-projection.ts`.
The security-domain assignment is injected by the caller so the domain layer
never points at the feature layer. Profiles do not yet declare a priority
basis, so the projection accepts one per source and falls back to `undeclared`
with its own diagnostic rather than guessing. Declaring the basis inside
`ToolProfileDefinition` is the follow-up step.
- 2026-09-18: Profile obligation implemented. `ToolPriorityBasisDefinition` is
required on every registered profile and enforced by a contract test. Purple
Knight and Maester declare native severity, PingCastle declares Points because
its severity is derived and capped, CIS-CAT and Generic declare unavailable
with a reason. CIS `Level` is recorded as an excluded property so it cannot be
promoted into a criticality later.
- 2026-09-18: Management Overview renderer implemented as
`src/exports/management-overview-report.ts`. Static, self-contained German
HTML with no script, inline handler, external URL or `url()`. Deviation from
the `assessor-ui` shell and page archetypes named above; all token, typography,
donut and status rules kept. An out-of-range score is clamped and a missing one
draws no arc at all.

63
docs/RISK_ACCEPTANCE.md Normal file
View File

@ -0,0 +1,63 @@
# Accepted Risk: decision record and storage contract
Status: implemented local documentation workflow; authenticated organizational approval remains a server-readiness requirement.
## Purpose and basis
Accepted Risk records an explicit, bounded business decision about a finding. It does not turn a failed scanner result into Passed, remove the finding, improve its technical score or waive a legal, contractual or policy obligation.
The workflow follows the separation of assessment evidence, accountable risk decisions, authorization conditions and monitoring described in [NIST SP 800-37 Rev. 2](https://csrc.nist.gov/pubs/sp/800/37/r2/final) and the [NIST RMF Authorize step](https://csrc.nist.gov/Projects/risk-management/about-rmf/authorize-step). These are design references, not a claim of certification or a universally binding approval policy. No form can guarantee that a decision is legally or organizationally valid in every context.
User decision, 2026-09-10: no existing organizational approval rules; provide a best-practice starting point. The application therefore requires a named risk owner and a different named approving authority with separate contacts, explicit approval evidence, a bounded validity period and a future review date. It does not invent a universal maximum acceptance duration or assume that a particular job title is authorized. Review frequency and acceptance duration must follow the documented exposure and business context.
## Recorded information
- Exact scope: affected assets/systems and exclusions, with the canonical finding occurrence linked automatically.
- Risk scenario, likelihood, business impact, initial risk, residual risk and justification for the remaining exposure.
- Business rationale, alternatives considered, and risk-register/ticket reference.
- Compensating controls, accountable control owner and effectiveness evidence; explicitly justify absent controls.
- Risk owner, role/team and contact; independent approving authority, role/decision authority and contact.
- External approval date and an attributable approval evidence reference covering scope, residual risk and validity.
- Validity start, expiry, next review, monitoring arrangements and review owner, escalation/reassessment triggers, and exit/remediation milestones.
- Applicable policy, regulatory and contractual considerations and any consulted specialists or restrictions.
- Additional evidence references, recorder name, revision reason, server recording timestamp and complete prior decision snapshots.
Evidence references are stored as inert text. The form neither uploads documents nor verifies their contents, signatures or availability. Source finding values and scanner provenance remain in the existing canonical model.
## States and rules
`draft`, `accept` and `revoke` are persisted revision actions. A partial draft requires a recorder and revision reason. Recording acceptance requires all substantive fields except additional evidence references, distinct owner/approver identities as declared, valid dates, approval on or before the start, a non-retroactive start, future expiry, and a future review date within the validity period. Revocation requires a recorder, reason and a previous acceptance; it retains the accepted decision's original details.
Displayed states are derived at read/render time: Not recorded, Draft, Scheduled, Accepted, Review due, Expired, Revoked and Review required. Expiry dates are inclusive UTC calendar dates; a review becomes due on its specified date. No scheduler or notification service is implied. A new draft becomes the current decision state; earlier approvals remain in history. Renewal or changed scope requires a new recorded acceptance, never editing an earlier snapshot.
The existing review workflow (including the historical `Actionable` → `accepted` mapping) is separate and is not repurposed for risk acceptance. Ordinary review edits preserve the structured record. Accepted Risk does not automatically alter bookmarks, remediation status or technical dashboard numbers.
## Implemented persistence
The authoritative record is `FindingReview.riskAcceptance`, an additive version-1 JSON object stored in the existing `assessor_reviews.data` JSONB column. It contains an append-only `revisions` array. Each revision contains action, complete details, sequential revision number, recorder, reason, original target occurrence ID, server-generated timestamp and `approvalIdentityVerified: false`.
No SQL migration is needed for this implementation. The canonical local datastore remains Docker project `assessor-2_0`, container `assessor-2_0-postgres-1`, database `assessor`, volume `assessor-2_0_assessor-postgres-data`, `127.0.0.1:5432`. The browser never accesses PostgreSQL directly.
Writes use `POST /api/v1/projects/:projectId/risk-acceptances` with occurrence ID, command and expected review revision. The backend verifies project membership and validates the decision. PostgreSQL locks the occurrence and current review within the repository transaction, appends the snapshot and increments the review/repository revisions atomically. The local reference repository implements the same contract. General `/reviews` writes cannot introduce, replace, remove or retarget a risk acceptance history. Conflicts fail without overwriting the previous record.
The UI reports successful storage only after the backend acknowledges the write. Disconnected/unsaved findings cannot record acceptance. Failed writes retain the dialog entries. Existing browser review caches and project snapshots can retain a copy; they are not the authority for acceptance. Exports include the complete structured review record. Existing project/scan deletion policies still apply: this is not an independent legal-hold archive.
## Reimport and retention
The existing stable-identity review transfer preserves the complete object and all original revision targets. It never matches by title or row position. If a replacement occurrence differs from the recorded target, the preserved acceptance displays Review required. A new decision can explicitly cover the replacement after reassessment. Failed or ambiguous matches retain the original review according to the reimport policy.
This refines preservation in `docs/REIMPORT_POLICY.md`: preserving a prior decision is not proof that a changed finding or new assessment is within its approved scope. Previous approvals, expiry dates and decision evidence must never be silently renewed.
## Security limits and planned server extension
The current local-only application has no authenticated approver identity, organizational role registry, digital signing, independent evidence verification, trusted immutable storage or legal-hold enforcement. Distinct names/contact entries and an approval reference document an external decision; they do not prove authorization or enforce separation of duties between authenticated users. Same-origin protections and revision checks protect the application workflow, not against a database administrator. These limits remain visible in the dialog and prevent any claim of a tamper-proof or universally compliant approval.
Before organization-wide approval in the browser/server target, add:
1. SSO-bound actors and client-scoped authorization; configurable risk-owner/approver roles, thresholds, separation of duties and delegation rules.
2. Dedicated `risk_acceptances`, `risk_acceptance_revisions`, `risk_acceptance_approvals` and `risk_acceptance_evidence` tables with immutable event IDs, project/occurrence foreign keys, actor IDs and server timestamps. Migrate current JSON history without replacing historical declared identities with verified identities.
3. Evidence references with content hashes, access controls, immutable versions and an organization-approved retention/legal-hold policy. Do not add external storage or uploads without a security-model update.
4. Transactional approval/revocation endpoints, tamper-evident audit retention, server-side due/expiry queries and an explicitly configured notification/monitoring workflow.
5. Tests against a dedicated approved integration environment for concurrent approvals, permissions, clock boundaries, evidence retention and migration round-trips. An approved persistence migration requires a verified backup of the canonical datastore first.
These are acceptance criteria for the future governance extension in phase 14, not completed features. Phase 7 covers the dialog, phases 9–11 cover its storage/API contract, and phase 13 covers security validation. Existing phases are not reopened or marked complete by this scoped implementation.

View File

@ -4,6 +4,24 @@ Decision: 2026-09-10, explicitly approved by the user. Implementation belongs to
Phase 7/13C (Client Info and scanner context), with score-comparison safeguards
under Phase 8. This decision supersedes the display-only native-score policy.
## Comparison qualification, 2026-09-11
The numeric contribution policy below remains unchanged. Its comparison safeguards are not a completed acceptance of object-level remediation or an explicitly selected A/B report. Code review identified an evaluation-population key without asset identity, weaker pending-revalidation checks than score pairing, and last-known-source replacement that can omit unresolved obligations after partial intermediate scans. Original stored findings are not deleted. The current historical baseline is not necessarily the user's selected A.
[TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) defines the open Phase-8 follow-up: verified object/scope populations, an explicit A/B basis, obligation-level history and separate eligibility rules for score deltas and remediation evidence. Matching `assessed` and `eligible` counts alone does not prove coverage of absent objects. Native totals must not be recomputed for an arbitrary smaller scope without separately retained contributions. Missing historical risks stay visible separately; no historical score is relabelled as a current measurement and no new penalty formula is introduced.
This is a planning clarification, not a runtime fix, processing-manifest change or completed verification. The earlier implementation/test notes below remain historical evidence for their stated scope; they do not satisfy the new follow-up's acceptance matrix.
## GPO scoring scope, decision 2026-09-16
The GPO contribution measures conformance of customer settings that are actually configured, independently comparable and inside an explicitly applicable Microsoft-baseline scope. It is not a measurement of complete Microsoft-baseline adoption. Recommended settings that are absent from the customer GPO set remain visible as `Not configured`, but they do not create operational findings, remediation obligations or score penalties in the normal assessment. Full baseline deployment is a separate explicitly commissioned hardening scope.
Clarification 2026-09-16: absence is reported within the analyzed backup scope. An applicable recommendation with no observed configured value in that imported set remains filterable as `Not configured`, including for a selected subset. This does not assert domain-wide absence or effective device state; the limited scope remains diagnostic evidence. The verdict remains excluded from the score.
Clarification later on 2026-09-16: `Not configured` is a score-neutral policy verdict. `Duplicates` and `Conflicts` are distinct diagnostic categories on a technical policy setting, not alternative verdicts. Roll up member comparisons once per technical setting: one independently comparable, in-scope configured member value with a valid `Fail` makes the complete policy-setting verdict `Fail`; retain the conflict/duplicate flags beside it. All applicable members passing yields `Passed`. Unresolved applicability, precedence or comparison yields `Review required`. Duplicate source rows, alternative presentations and diagnostic flags never add score weight.
This clarification supersedes only the earlier same-day interpretation that a conflict/duplicate container could remain score-neutral despite a proven member fail. It is binding for new GPO scoring work. Current runtime categories remain disjoint, so the rolled-up verdict plus diagnostic-flag model requires a dedicated result-population identity, synthetic conflict/duplicate fixtures and explicit verification before it may be claimed as implemented. If implementing it changes persisted evaluation semantics or score inputs, version the affected evaluator/scoring contract and follow the reimport/re-evaluation policy; do not reinterpret historical runs silently.
## Scan contribution
Use a valid native score from the exact import file and scan run. Otherwise use
@ -16,7 +34,7 @@ pass rate. Never combine the native score and the pass rate of the same scan.
| CIS-CAT | Native benchmark score, 0–100 | Pass rate |
| PingCastle Healthcheck | `100 - native risk`, bounded 0–100 | No pass rate from findings-only reports |
| Maester | No native numeric score assumed | Pass rate |
| GPO Analysis | None | Existing context-aware pass rate |
| GPO Analysis | None | Pass rate of configured, independently comparable in-scope settings |
| Additional registered scanners | Native overall score declared by their profile | Declared result-population fallback |
| Unrecognized tabular sources | No native score inferred | Provisional pass rate only with evidence of passed results |
@ -32,8 +50,12 @@ integrity diagnostics are visible in the calculation details.
Passed and deviations use normalized outcomes. Not applicable is excluded.
Execution errors, not run and unknown results do not pass and do not add a
security failure. They affect known-check coverage. GPO context states retain
their existing authoritative disposition; no severity or missing-setting
penalty is invented. Findings-only input cannot establish a pass denominator.
their existing authoritative disposition. `Not configured` does not enter the
GPO denominator. Conflict/duplicate flags do not enter independently; the
rolled-up policy verdict enters once and is `Fail` when any independently
comparable, in-scope configured member fails. No
severity or missing-setting penalty is invented. Findings-only input cannot
establish a pass denominator.
## Future scanner support

View File

@ -0,0 +1,38 @@
# Timeslot comparison capability audit
Stand: 2026-09-11. Phase 8, Paket P0/P1. Code- und synthetisches Fixture-Audit; ergänzend wurde der vom Nutzer ausdrücklich vollständig freigegebene Mandant `Dummy` ausschließlich lesend geprüft. Es gab keine Datenmutation.
## Ergebnis
Die aktuelle kanonische Persistenz bewahrt Tool-/Regel-ID, optional ein generisch gemapptes `affectedAsset`, Originalfelder und Provenienz. Sie bewahrt außerdem scannergebundene Ziel-/Benchmarkbeobachtungen für die Scoreprojektion. Diese Angaben reichen nicht pauschal für eine objektgenaue Gegenprüfung: Der Assetwert besitzt keinen deklarierten Typ oder Namensraum, Score-Scope und Finding-Vorkommen sind nicht allgemein über einen versionierten Vergleichsvertrag verbunden, und kein Profil deklariert derzeit eine vollständige Negativnachweissemantik je Regel/Objekt.
| Quelle | Unterstützte Formate/Pfade | Erhaltener übergeordneter Scope | Ergebnispopulation | Objektgenaue Behebung aus heutigem Bestand |
|---|---|---|---|---|
| Purple Knight | CSV/Excel/HTML über generische Tabellen plus native Summary-/HTML-Extraktion | `forest.name`; AD-native Scorequelle | `complete` für Scoreberechnung | Nicht allgemein belegt. `asset` ist heuristisch gemappt; Typ/Namensraum und vollständige Objektpopulation fehlen |
| PingCastle | XML/HTML über Healthcheck-Extractor | `domain.fqdn` | `findings-only` | Abwesenheit beweist keine Behebung. Native Regel-ID ist erforderlich, aber Ergebnislisten enthalten nur ausgelöste Regeln |
| CIS-CAT | HTML sowie generische Tabellen/Objektpfade | `host.name` und `benchmark.name` | `complete` für Scoreberechnung | Scopevergleich grundsätzlich möglich; regel-/objektbezogener Negativnachweis und Namespaceregel noch nicht deklariert |
| Maester | Excel/CSV/HTML über generische Tabellen bzw. HTML-Extractor | `entra.tenant_id` | `complete` für Scoreberechnung | Tenant-/Regelvergleich grundsätzlich möglich; `asset` ist keine verpflichtende, typisierte Zielidentität und vollständige Negativsemantik fehlt |
| Generisch | CSV/Excel/JSON/XML/HTML über registrierte Formatadapter und generischen Extractor | kein verifizierter Zielscope | `unknown` | Nicht automatisch vergleichbar; Titel, Position oder untypisierter Assettext dürfen keine Behebung beweisen |
| GPO/Baseline | Eigener versionierter Ingestion-, Identity-, Snapshot-, Evaluation- und Finding-Pfad | Projekt, Environment Profile, Kontext/Baseline/Setting und Backupreferenzen | explizite Complete/Partial/Blocked- und Evidencezustände | Fachlich reichere Identitäten vorhanden, aber ohne RSoP/GP Results keine allgemeine Aussage über effektive Anwendung; eigener Policyvertrag bleibt maßgeblich |
Die älteren `comparisonKeys` bleiben vorerst als Legacydeklaration vorhanden. Zusätzlich muss jetzt jedes Scannerprofil einen versionierten `timeslotComparison`-Vertrag besitzen. Purple Knight, PingCastle, CIS-CAT und Maester sind zunächst ausschließlich scopeweit freigegeben; das generische Profil ist nicht automatisch vergleichbar. Kein aktuelles Profil erlaubt Behebung durch bloße Abwesenheit. Die neue sichere Engine konsumiert diese Verträge; die bisherige Produktprojektion konsumiert sie noch nicht.
## Ergänzende Bestandsvalidierung mit Dummy
- Purple Knight besitzt im aktiven Bestand Regel-IDs und für jedes Ergebnis einen `affectedAsset`-Wert. Die Werte sind jedoch nur die Ergebnisbereiche `AD` beziehungsweise `Hybrid (AD)`, keine stabilen Host-/Objektidentitäten. Der Vertrag bleibt deshalb korrekt auf `forest.name` und Scopegranularität begrenzt.
- CIS-CAT besitzt `host.name`, `benchmark.name` und `report.generated_at`; PingCastle besitzt `domain.fqdn` und `report.generated_at`; Purple Knight besitzt `forest.name` und `report.generated_at`. Der aktive Maester-Excel-Lauf besitzt keinen gespeicherten `entra.tenant_id`- oder Reportzeitnachweis und ist nach dem neuen Vertrag aktuell nicht vergleichbar.
- Dummy enthält mehrere supersedierte Reimporte derselben Scannerquellen. Die Läufe besitzen weder `startedAt` noch `completedAt`; ihre Importzeiten unterscheiden sich. Diese Importzeiten dürfen und werden nicht als neue Sicherheitsbeobachtung verwendet.
- Die derzeit aktiven Dummy-Quellen liegen nicht als zwei vollständig vergleichbare A/B-Timeslots desselben Scanners vor. Dummy kann daher aktuell keinen realen Fortschrittswert abnehmen; er bestätigt stattdessen die Fail-closed-Grenzen für Scope, Reportzeit und Reimport.
## Versionierungsentscheidung für den ersten Schnitt
Der neue Domänenvertrag in `src/domain/timeslot-comparison.ts` ist additiv und wird noch nicht von Import, Persistenz, bestehender Vergleichsengine oder UI verwendet. Er verändert keine gespeicherten Fakten und benötigt deshalb für diesen ersten Schnitt weder Processing-Manifest noch Reimport.
Sobald ein Profil Objekt-/Scopefelder neu extrahiert, anders mappt oder eine neue Vollständigkeits-/Negativnachweisaussage aus dem Quellformat ableitet, gilt `REIMPORT_POLICY.md`: append-only Parser-/Extractor-/Profil-/Mappingversion und Reimportmarkierung nur für die betroffenen historischen Verträge. Bereits vollständig gespeicherte, verifizierbare Fakten dürfen später über eine getrennt versionierte Vergleichsprojektion neu ausgewertet werden; fehlende Evidenz darf nicht rekonstruiert werden.
## P1-Profilstand und offene Entscheidungen
- Scopegranularität, notwendige Scopeeigenschaften, Reportzeitquellen, Ergebnisart und Diagnosepolitik sind für alle registrierten Profile deklariert und synthetisch validiert. Objektgranularität bleibt für alle aktuellen Scannerprofile gesperrt.
- `complete` trägt bei keinem aktuellen Profil automatisch eine negative Regel-/Objektaussage. Abwesenheit bleibt `unverified-absent`, bis ein formatspezifischer vollständiger Negativnachweis synthetisch belegt und versioniert ist.
- Scannerdiagnosen nach Einfluss auf Identität, Einzelresultat, Abdeckung und Gesamtscore klassifizieren.
- GPO-Vergleichsvertrag getrennt auf vorhandene Policy-Identitäten und Wirksamkeitsgrenzen abbilden; keine Scanner-Sonderlogik in React.

View File

@ -0,0 +1,157 @@
# Belastbarer Timeslotvergleich: Umsetzungs- und Abnahmeplan
Stand: 2026-09-11. Zugehörigkeit: Phase 8, mit UI aus Phase 7, Persistenz/API aus Phasen 9-11 und erforderlichen Profilverträgen aus Phasen 6/13A/13C; GPO-Anbindung über Phase 13B.
Status: **in Arbeit, nicht fachlich abgenommen**. P0 ist abgeschlossen. P1 besitzt den additiven Domänenvertrag und verpflichtende profilgebundene Scope-/Zeit-/Evidenzdeklarationen; Persistenz/API und Übergangsvertrag bleiben offen. P2 ist begonnen: `compareProjectTimeslotEvidence` paart echte Scannerläufe je Werkzeug und bewertet Processing, Scope, Reportzeit, Diagnosen sowie Fail-zu-Pass konservativ. Die bisherige Engine und Oberfläche verwenden diesen Pfad noch nicht. P3-P6 sind offen. Der aktuelle Implementierungsschnitt führt keine Migration, Reimporte, Datenmutation oder Aktivierung einer Vergleichsoberfläche aus. Der historische Phase-8-Abschluss vom 2026-07-17 bleibt dokumentiert, ist aber keine Abnahme dieses Arbeitspakets.
Dieser Plan präzisiert das Arbeitspaket „faire Vergleiche bei fehlenden Folgequellen“ in [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md). Für die künftige Umsetzung ersetzt er ausdrücklich die ältere Schlussfolgerung „abwesend = behoben“ und die pauschale Ablösung offener Verpflichtungen durch einen neuen Lauf derselben Quelle. Historische Ergebnisse und Originaldaten werden dadurch nicht rückwirkend geändert.
## 1. Ziel und Grenzen
Zwei ausdrücklich gewählte Timeslots A und B desselben Kunden liefern drei getrennte Aussagen:
1. **Nachweislich behoben:** zuvor bestätigte Abweichungen mit nachvollziehbarer aktueller Gegenprüfung.
2. **Weiterhin offen oder ungeklärt:** aktuell bestätigte sowie historische, noch nicht ausreichend erneut geprüfte Verpflichtungen; neue und wieder aufgetretene Abweichungen getrennt ausweisen.
3. **Vergleichbare Scoreveränderung:** A und B werden auf derselben belegten Prüfgrundlage berechnet; ausgeschlossene Bereiche, Evidenzabdeckung und Aktualität bleiben sichtbar.
Der Overall Score bleibt der methodisch gemischte Index aus [SCORING_MODEL.md](SCORING_MODEL.md), kein Prozentsatz tatsächlicher Sicherheit. Keine neuen Risikogewichte, Scannerformeln oder automatische Übertragung nativer Gesamtwerte auf Security Domains. Fehlende Daten sind weder null noch bestanden. Unbekannte Prüfgrundlagen führen zu einer begrenzten oder nicht verfügbaren Aussage.
## 2. Ausgangsbefund und Reparaturbedarf
Codeprüfung vom 2026-09-11, keine neue Laufzeitabnahme:
- `compareProjectTimeslots` in [datahub-scan-analysis.ts](../src/features/project-import/datahub-scan-analysis.ts) besitzt derzeit keinen Produktaufrufer. Mehrfachauswahl/Öffnen ist kein expliziter A/B-Bericht.
- Die Timeslot-Projektion ersetzt beide Laufwerkzeuge durch `multiple`, lässt `importFiles` aber an Original-Lauf-IDs. Dadurch kann `sameTool` fälschlich gelten und die dateigebundene Kompatibilitätsprüfung ausfallen.
- Die automatischen Matchingstufen der [comparison-engine.ts](../src/comparison/comparison-engine.ts) erzwingen keine objektgenaue Identität. Eine gemeinsame Regeldefinition ist kein Nachweis desselben betroffenen Objekts. Die Zwei-Lauf-Projektion entfernt außerdem frühere Beobachtungen, die zur Verifikation eines Wiederauftretens gebraucht werden.
- Abwesende Vorkommen können `resolved` werden, während zugeordnete Fail-zu-Pass-Ergebnisse `persistent` mit Statusänderung bleiben. Die lokale `isOpen`-Prüfung verwechselt fachliche Abweichungen mit technischen beziehungsweise ungeprüften Zuständen.
- Die Score-Paarbildung prüft viele Kompatibilitätsbedingungen. Ihr `evaluationScopeKey` enthält jedoch keine Asset-Identität; eine unveränderte Regelanzahl kann einen Objektwechsel verdecken.
- `awaitingRevalidation` verwendet schwächere Bedingungen als die Score-Paarbildung. Zusätzlich überschreibt `lastKnown` im [Workspace-Lader](../src/features/project-import/load-canonical-project-workspace.ts) ältere Quellstände, ohne jede offene Verpflichtung zu erhalten. Das kann Verpflichtungen aus der Anzeige verdrängen, löscht aber keine gespeicherten Originalfindings.
- Die derzeitige Score-Baseline ist ein historischer letzter Stand je Quellscope vor der geöffneten Auswahl, nicht zwingend der ausdrücklich gewünschte Timeslot A.
## 3. Fachliche Verträge
### 3.1 Regel, Vergleichsidentität und Beobachtung
- `FindingDefinition` bleibt eine Regeldefinition. Kein pauschaler Ersatz ihres `tool:rule`-Schlüssels durch `tool:rule:objekt`.
- Die projektgebundene Vergleichsidentität eines Vorkommens umfasst verifizierte Quelle, native Regel, fachlichen Scope und gegebenenfalls die stabile Identität des betroffenen Objekts. Zeitpunkt und Importlauf gehören zur Beobachtung und Provenienz, nicht in den dauerhaften Vergleichsschlüssel.
- Objektart, Namensraum und Geltungsbereich gehören zum Identitätsvertrag. Fehlende Identität ist nicht die leere Identität eines vermeintlich gleichen Objekts. Anzeigenamen, Positionen und Titelähnlichkeit reichen nicht aus; Umbenennungen oder Aliasbeziehungen dürfen nicht geraten werden.
- Objektbezogene, scopeweite und aggregierte Regeln erhalten deklarierte Vergleichsgranularitäten. Für einen domainweiten Befund wird kein Host erfunden. Eine Regel mit einer Liste betroffener Objekte ist ohne geeignete strukturierte Evidenz nicht automatisch eine Menge eigenständiger Instanzfindings.
- Automatische Zuordnung muss beidseitig eindeutig auf der deklarierten Granularität sein. Unsichere Zuordnungen bleiben Vorschläge; sie dürfen weder Behebung noch neue Abweichungen allein aus unklarer Restmengenbildung beweisen. Lokale bestätigte Regeln dürfen diese Scope-/Objektgrenzen nicht umgehen.
- Der bereits vorhandene objektbezogene Reimport-Reviewtransfer ist eine wiederzuverwendende Grundlage, aber kein ungeprüfter universeller Identitätsvertrag. Definitionsreviews, Vorkommensreviews und Accepted Risk behalten ihren jeweiligen Geltungsbereich.
### 3.2 Gegenprüfung und Lebenszyklus
Die Ergebnispräsenz und der Lebenszyklus einer Abweichung werden getrennt betrachtet. Eine Regel kann weiterhin als Ergebnis vorhanden sein, obwohl die konkrete Abweichung behoben ist.
| Evidenz im Ziel B gegenüber einer bestätigten Abweichung | Fachliche Aussage |
|---|---|
| Eindeutig dasselbe Objekt/derselbe Scope, kompatible Prüfung, neueres belastbares Pass-Ergebnis | nachweislich behoben |
| Dieselbe Vergleichsidentität mit bestätigter aktueller Abweichung | weiterhin offen |
| Quelle, Objekt oder Ergebnis fehlt; Prüfung abgebrochen, unbekannt oder mehrdeutig | ungeklärt, Verpflichtung bleibt bestehen; `unverified-absent` für ungeprüfte Abwesenheit |
| Finding fehlt in einem deklarativ vollständig belegten Negativbericht für genau diese Regel und dieses Objekt | Behebung nur bei erfülltem profilgebundenem Negativnachweisvertrag |
| Nicht anwendbar oder aus dem Scope genommen | keine Behebung; eigene belegte Anwendbarkeits-/Scopeentscheidung |
| Manuell als erledigt markiert oder Risiko akzeptiert | Workflow-/Risikoentscheidung, kein technischer Behebungsnachweis |
Neue und wieder aufgetretene Abweichungen verlangen ebenfalls ausreichende Historie und Vergleichbarkeit. Eine bloße Lücke im direkten Vorgänger ist kein Nachweis einer zwischenzeitlichen Behebung. Ein späterer Fail nach belegtem Pass derselben Identität ist ein Wiederauftreten; nach einer ungeprüften Lücke bleibt eine alte Verpflichtung offen.
Ein Gegenprüfungsvertrag je Profil/Format beschreibt Regelpopulation, Zielobjekte, erfolgreichen Ausführungsumfang, positive/negative Berichtsemantik, Zeitbezug, notwendige Provenienz und disqualifizierende Diagnosearten. GPOs verwenden ihre vorhandenen Settings-, Scope-, Baseline- und Evaluationsverträge; ohne Wirksamkeitsevidenz wird keine effektive Anwendung behauptet.
`assessed` und `eligible` sind unterstützende Signale, kein Beweis vollständig untersuchter Objekte. Ein im Report vollständig fehlendes Objekt kann beide Zähler unverändert lassen. Fehler werden nach ihrem Einfluss auf Identität, einzelne Prüfergebnisse, Abdeckung oder den Gesamtscore klassifiziert. Eine belastbare Einzelprüfung kann trotz fachlich unabhängiger Fehler verwendet werden; unbekannte Fehlerauswirkung verhindert automatische Behebung.
### 3.3 Dauerhafte offene Verpflichtungen
- Jede offene Verpflichtung behält Ursprungs-Timeslot, Originalvorkommen, Quelle, Objekt/Scope, letzten bestätigten Zustand und Beobachtungszeitpunkt. Über A, einen unvollständigen Zwischenstand und B darf sie nicht durch „letzter Scan je Quelle“ verschwinden.
- Fortschreibung kopiert keine Rohbeobachtung als aktuelle Evidenz. Sie erhält eine Referenz mit Zustand, Grund und Alter; neue Beobachtungen werden dazu verknüpft.
- Ablösung erfolgt je Verpflichtung durch aktuelle Bestätigung, belegte Behebung oder eine ausdrücklich auditierte Scopeentscheidung. Eine spätere Dateizeit, ein nichtleerer Scan oder `assessed > 0` genügt nicht.
- Die erwartete Quelle kann aktuelle und ungeklärte Teilbereiche zugleich besitzen. Die bisherigen Quellzustände `observed-current`, `carried-forward-awaiting-revalidation` und `retired-out-of-scope` dürfen diesen Mischfall nicht zu „alles aktuell“ verdichten.
- Backlog und Evidenzabdeckung sind getrennt vom gemessenen Score. Historische Risiken verschwinden nicht bei Quellenauswahl; ein alter Score wird nicht als Messwert von B eingesetzt. Die frühere Formulierung „scorewirksam“ autorisiert keinen erfundenen numerischen Malus. Ein zusätzlicher risikogewichteter Expositionsindex benötigt eine eigene Methodikentscheidung.
- Risikoakzeptanz bleibt separat sichtbar und beendet keine technische Abweichung. Reviewhistorie einschließlich zukünftiger Felder wird vollständig erhalten; ein Reimport verlängert oder erneuert keine Akzeptanz.
### 3.4 Vergleichspaar und Score
- Der Vergleich bindet genau A und B samt Projekt-, Assessment-, aktiven Run-, Datei- und Processingreferenzen. A muss fachlich vor B liegen; ungültige, identische oder nicht eindeutig geordnete Auswahlen liefern keinen verifizierten Fortschritt. Scanzeit wird nicht durch Importzeit ersetzt.
- Mehrteilige Dateien werden je originalem Scannerlauf geprüft. Mehrere Quellen dürfen nicht zu einem gemeinsamen `multiple`-Vertrag verschmolzen werden. Fehlende oder mehrdeutige Zuordnung sperrt die betroffene Aussage; übrige vergleichbare Bereiche bleiben mit ausgewiesenem Umfang nutzbar.
- Historie vor A darf offene Verpflichtungen und Wiederauftreten erklären, aber weder den Messwert von A ersetzen noch ungefragt die A/B-Scorebasis verändern.
- Der Scorevergleich verlangt eine gemeinsame belegte Prüfpopulation einschließlich der erforderlichen Objekt-/Scopeidentitäten, eindeutige Paarung, kompatible Verarbeitung, neuere Beobachtung sowie gleiche Scoremethodik und Gewichtung. Fehlende, hinzugekommene, entfernte oder ausgetauschte Objekte werden als Scopeänderung erkennbar, auch bei gleicher Anzahl.
- Native Gesamtwerte dürfen nicht auf eine kleinere Objektteilmenge heruntergerechnet werden, wenn deren Beitrag nicht separat belegt ist. Ohne vergleichbare native Grundgesamtheit ist für diesen Beitrag kein Delta verfügbar. Pass-rate-Teilmengen sind nur mit ausgewiesenen, vollständig identifizierten Prüfungen zulässig.
- Score- und Behebungsfreigabe sind getrennte Prädikate. Eine geänderte Gewichtung oder ein unbrauchbarer nativer Score kann das Delta verhindern, ohne eine belegte Einzelbehebung zu entwerten. Umgekehrt ist ein vergleichbarer Gesamtscore kein Behebungsnachweis.
- Die bestehenden Regeln für aktuelle Abweichungen werden gemeinsam verwendet (`isDeviation`, für GPOs `policyComparisonDisposition`). Fehler/Not run/Unknown bleiben Evidenzlücken. Historisch ungeklärte Verpflichtungen bleiben zusätzlich offen, auch wenn B keine aktuelle Abweichung dazu enthält.
Behebungsquoten beziehen sich auf eine explizite, deduplizierte Menge bestätigter offener Vergleichsinstanzen aus A. Der Zähler enthält nur nachgewiesene Behebungen dieser Menge; ungeklärte, nicht vergleichbare und aus dem Scope genommene Instanzen werden separat ausgewiesen und nicht still aus dem Nenner entfernt. Ein unbekannter oder leerer Nenner liefert keine behauptete Erfolgsquote. Neue Findings aus B und zusätzliche Verpflichtungen vor A gehören in den getrennten Bestandsbericht. Die reine Differenz offener Anzahlen ist ausdrücklich keine Behebungsquote; jede Kennzahl muss auf ihre zugrunde liegenden Identitäten zurückführbar sein.
## 4. Arbeitspakete und Reihenfolge
Die Paketstände beziehen sich ausschließlich auf nachgewiesene Ergebnisse dieses Arbeitspakets; ein begonnener Vertrag ist noch keine Produktintegration.
| Paket | Inhalt und betroffene Grenzen | Voraussetzung / Nachweis |
|---|---|---|
| P0 · abgeschlossen | Synthetisches Bestandsaudit je Scanner/Format/GPO: Regelgranularität, bereits erhaltene Objekt-/Scopefelder, Quellenpfade, Vollständigkeitssignale und fehlende Informationen dokumentieren; notwendige Vertragsänderungen und Reimportbetroffenheit bestimmen | [TIMESLOT_COMPARISON_AUDIT.md](TIMESLOT_COMPARISON_AUDIT.md); keine echten Kundendaten; Fähigkeiten und Grenzen je unterstütztem Pfad dokumentiert |
| P1 · in Arbeit | Versionierte Vergleichsidentität, Gegenprüfungs-/Diagnosevertrag und getrennte Ergebnis-/Verpflichtungszustände in der Domäne spezifizieren; Persistenz-/API-Entwurf und Übergangsregeln festlegen | Domänen- und Profilverträge sowie synthetische Scope-/Zeitfälle umgesetzt; Persistenz-/API- und Übergangsentwurf bleiben offen |
| P2 · in Arbeit | Scanner-/GPO-Paarung anhand echter Quellreferenzen; vollständige Kompatibilitätsprüfungen; objektgenauer Lebenszyklus, Fail-zu-Pass, konservative Abwesenheit und einheitliche aktuelle Abweichungsmetriken | Sicherer Scannerpfad mit echter Tool-/Run-/File-Paarung, Processing-, Scope-, Reportzeit- und Diagnosegrenzen umgesetzt; GPO, Historie, Produktintegration und vollständige Matrix bleiben offen |
| P3 | Verpflichtungsbezogene Fortschreibung über alle Zwischenstände, getrennt von der Score-Baseline; atomare/idempotente Ablage und API gemäß P1, Wiederladen und auditierter Scope-Ausstieg | P1/P2; nichts verschwindet bei Teilimport, Reload, Reimport oder mehrdeutiger Zuordnung |
| P4 | Explizite A/B-Scorebasis, objekt-/scopebewusste Population und getrennte Freigaben für Delta/Gegenprüfung; native Scoregrenzen und Coverageausgabe | P1/P2/P3; keine Verbesserung durch Quellenauslassung, Objektwechsel oder historische Baselinevermischung |
| P5 | A/B-Auswahl und dreiteiliger Bericht in der vorhandenen Oberfläche; Gründe, Messbasis, Quellen/Versionen und Belege pro Aussage; aktueller Workspace bleibt unverändert nutzbar | P2-P4 fachlich verifiziert; UI ist Konsument der Domänenlogik, kein eigener Matcher |
| P6 | Gesamtabnahme, Dokumentationsabgleich und kontrollierte Aktivierung | Alle unten genannten Fälle; keine Freigabe allein wegen vorhandener Funktion oder grüner Buildausgabe |
Keine frühe Verdrahtung der bisherigen `compareProjectTimeslots`-Ergebnisse als belastbarer Fortschrittsbericht. Nach Implementierungsbeginn und jedem verifizierten Teilpaket werden Status, offene Risiken und tatsächliche Nachweise im Hauptplan fortgeschrieben.
## 5. Versionierung, Bestand und Betrieb
- [REIMPORT_POLICY.md](REIMPORT_POLICY.md) bleibt verbindlich. Änderungen an importierter Identität, Findinggrenzen, Assetmapping, Normalisierung, Extraktion, Vollständigkeitsdiagnosen oder neu benötigten Scoreeingaben erhalten append-only Processing-Manifeste und Reimportmarkierungen für betroffene historische Verträge.
- Eine Änderung allein an der Vergleichsauswertung unveränderter, bereits ausreichend gespeicherter Fakten wird getrennt versioniert und benötigt eine nachvollziehbare Neuberechnung der Vergleichsergebnisse. Sie darf keine versteckte Änderung importierter Identität oder eine Ergänzung fehlender Evidenz sein. P0/P1 müssen diese Grenze pro Änderung dokumentieren; kein pauschaler Bestandsreimport und keine pauschale Reimportfreiheit.
- Neue Vergleichsergebnisse erhalten eine eigene Algorithmus-/Vertragsversion, genaue Eingabereferenzen und Freigabegründe. Historische Ergebnisse behalten ihre Bedeutung und werden als Legacy/nicht nach neuem Vertrag verifiziert gekennzeichnet; alte `resolved`-Werte werden nicht rückwirkend zu Beweisen erklärt.
- Der konkrete Speichervertrag für Verpflichtungen und Scopeentscheidungen wird vor Datenbankänderungen festgelegt. Persistenz/Backend bleiben außerhalb von React; Projektgrenzen, optimistische Revisionen, atomare Updates und Wiederholbarkeit sind Pflicht.
- Vor einer gesondert freigegebenen Migration: Backup erstellen und prüfen, Migration/Rückfallplan synthetisch verifizieren. Nur den kanonischen lokalen PostgreSQL-Datastore verwenden; keine Ersatzdatenbank, keine neuen Ports oder Volumes. Der Planauftrag führt weder Migration noch Reimport aus.
- Kundenreviews und Accepted-Risk-Historien dürfen weder verloren gehen noch auf andere Objekte geraten werden. Nicht eindeutig übertragbare Daten bleiben am Ursprung mit dauerhaft sichtbarem Klärungsbedarf.
## 6. Verbindliche synthetische Abnahmematrix
Die Matrix bleibt bis auf ausdrücklich im Paketstatus genannte Teilnachweise geplant. Domain-, Import-/Projektions-, Repository-/API- und UI-Ebene werden entsprechend ihrer Verantwortung geprüft.
| Fall | Erwarteter Nachweis |
|---|---|
| Dieselbe Regel auf Server A in A und Server B in B | Kein objektgenaues Match und keine Behebung für Server A |
| Fünf betroffene Objekte in A, drei erneut vorhanden in B | Drei nur bei Identitätsbeweis zugeordnet; zwei ungeklärt, niemals allein aus der Differenz behoben |
| Gleiche Anzahl und Regeln, aber ausgetauschte Zielobjekte | Scopeänderung; kein ungeprüftes vergleichbares Score-Delta |
| Dasselbe Objekt: Fail zu Pass / Fail zu Fail | Belegt behoben / weiterhin offen; Ergebnispräsenz ersetzt keinen Lebenszyklus |
| Kein oder doppelter Objektschlüssel, gleiche Titel, Umordnung | Kein geratenes Match; deterministische Diagnose und vollständige Erhaltung aller Vorkommen |
| Domainweite oder aggregierte Regel ohne Host-ID | Deklarierte Scopegranularität statt erfundener Objektinstanzen |
| Gesamte Scannerquelle fehlt; generische Begleitdateien bleiben | Offene Verpflichtungen bleiben, fehlende Quelle zählt nicht als Behebung |
| Unvollständiger Folgescan mit `assessed > 0` und Score-Issues | Kein pauschales Entfernen historischer Verpflichtungen; einzelne belegte Prüfungen getrennt behandelbar |
| A offen, Zwischenstand teilweise/leer/fehlerhaft, B ohne Quelle | Ursprüngliche Verpflichtung mit Originalherkunft bleibt sichtbar, auch nach Reload |
| `assessed === eligible`, aber ein erwartetes Objekt fehlt | Zählergleichheit allein erteilt keine Vollständigkeits- oder Behebungsfreigabe |
| Findings-only-Bericht enthält keinen Treffer | Behebung nur mit explizit erfülltem vollständigem Negativnachweisvertrag |
| Neuerer Scanzeitpunkt fehlt oder ist unverändert; bloßer Reimport | Kein gemessener Fortschritt aus Importzeit oder identischen Beobachtungen |
| Geänderte/ unbekannte Processingverträge, gemischte Scanner und Mehrdateiläufe | Schutz greift auch im Timeslotvergleich; keine Umgehung durch künstliche Lauf-IDs |
| Gewichtung/Scoremethode geändert, Einzelprüfung dennoch belastbar | Delta gesperrt/begrenzt, gültige Behebung unabhängig davon bewertbar |
| Neuere Evidenz wieder Fail nach belegtem Pass / nach ungeprüfter Lücke | Wiederaufgetreten / alte Verpflichtung weiter offen |
| Ältere Historie H vor A und B | A/B-Delta unverändert durch H; historische Verpflichtungen separat und nachvollziehbar |
| Keine vergleichbare Quelle oder fehlende Historie | Kein Null-Delta als Erfolg; nicht verfügbare Aussage mit Grund |
| Not applicable, Not run, Execution error, Unknown und Accepted Risk | Keine künstlichen Behebungen; aktuelle Abweichung, Evidenzlücke und Risikoentscheidung getrennt |
| GPO-Teilabdeckung, andere Baseline/Scope, fehlende Wirksamkeitsevidenz | Konservative getrennte Aussage gemäß Policyvertrag, keine erfundene effektive Behebung |
| Scope-Ausstieg mit/ohne ausdrückliche Begründung und Akteur | Nur auditierte Entscheidung wirksam; nicht als Behebung gezählt; Historie bleibt erhalten |
| Reimport, doppelte Anforderung, Konflikt oder fehlgeschlagene Transaktion | Keine verlorenen/doppelten Verpflichtungen; vollständige Reviews und Accepted Risk erhalten |
| Mandantenwechsel, manipulierte A/B-IDs, supersedierte Läufe | Projektgrenze und aktive Auswahl durch Domäne/API abgesichert |
| Auswahlwechsel, Filter, wiederholtes Öffnen, schmale Ansicht | Kein veraltetes Delta, vollständige Belege, klare A/B-Beschriftung, begrenzte Renderarbeit |
Für relevante Codeänderungen sind `npm test` und `npm run build` die Basis; zusätzlich gezielte Vergleichs-/Score-, Historien-, Importprojektions-, Persistenz-/API- und synthetische UI-Tests. Optionale PostgreSQL-Tests werden nur im freigegebenen Rahmen ausgeführt; übersprungene Tests ersetzen keine Persistenzabnahme. Laufzeitbeobachtungen sind keine fragilen Millisekunden-CI-Grenzen. Keine echten Reportinhalte, Screenshots gefüllter Kundenansichten, Telemetrie oder externen Uploads für die Verifikation.
## 7. Offene Detailentscheidungen und Abschlusskriterium
- P0/P1: exakte Identitätsfelder, Alias-/Umbenennungsbelege, Granularitäten und Negativnachweisfähigkeiten je unterstütztem Profil/Format; fehlende Fähigkeiten bleiben ausdrücklich nicht verifizierbar.
- P1/P3: additive Datensätze/Versionen für Verpflichtungen, Vergleichsergebnisse und Scopeentscheidungen; Rückfall-/Neuberechnungspfad, Projektberechtigungen und Grenzen der vorhandenen Akteursnachweise. Keine Behauptung einer authentifizierten Signatur ohne entsprechende Infrastruktur.
- P4: welche separat belegten Teilpopulationen pro Scoreverfahren tatsächlich berechenbar sind. Keine frei geschätzte Zerlegung nativer Gesamtscores; kein zusätzlicher Expositionsindex ohne separate Entscheidung.
- P5: konkrete Bedienposition und Belegnavigation im bestehenden Designsystem; keine neue Oberfläche oder neue Exportfunktion außerhalb des Vergleichsauftrags.
Das Paket ist erst abgeschlossen, wenn die Matrix auf den relevanten Schichten bestanden ist, dokumentierte Profilgrenzen in der Oberfläche sichtbar bleiben, A/B-Auswahl und Herkunft reproduzierbar sind und weder fehlende Evidenz noch Objekt-/Methodenwechsel als Verbesserung erscheinen. Plan, Vergleichsdokumentation und Scoringdokumentation müssen denselben verifizierten Stand beschreiben. Bis dahin lautet der Status weiterhin offen beziehungsweise nach Beginn in Arbeit.
## Verifikation der bisherigen Schnitte, 2026-09-11
- Sechs synthetische Domänentests bestätigen getrennte Regel-/Objektidentität, fehlende Typ-/Namespacefreigabe, Fail-zu-Pass nur bei gleicher Identität, konservative Abwesenheit, Diagnose-/Zeitgrenzen sowie Herkunftserhalt und auditierten Scope-Ausstieg.
- Client- und Serverbuild, Local-only-Sicherheitsprüfung und Designprüfung sind erfolgreich.
- Gesamtlauf: 1.224 Tests bestanden, acht optionale PostgreSQL-Tests übersprungen. Ein fachfremder kombinierter Node-Importtest überschritt unter Gesamtlast einmal sein unverändertes Fünf-Sekunden-Limit und bestand isoliert mit 5/5 Tests in 1.193 ms.
- Der erste Schnitt enthielt keine reale Dateninspektion. Über alle bisherigen Schnitte gab es keine Persistenzmutation, Migration, Reimport- oder UI-Aktivierung; P1/P2 bleiben ausdrücklich unvollständig.
- Nach ausdrücklicher Nutzerfreigabe wurde ausschließlich `Dummy` lesend ergänzt geprüft. Der Bestand bestätigt, dass Purple-Knight-`affectedAsset` nur Bereiche statt Objekte enthält und dass wiederholte Reimporte ohne neue Reportzeit keinen Fortschritt belegen. Keine Daten wurden verändert.
- Vier Profile besitzen nun scopegebundene Vergleichsverträge; Generic bleibt nicht automatisch vergleichbar. Die sichere Scannerpaarung zählt nur ein eindeutiges Fail-zu-Pass derselben Identität bei kompatibler Verarbeitung und neuerer Reportzeit als behoben. Fehlende Quellen, Abwesenheit und gleiche Reportzeit bleiben ungeklärt oder nicht vergleichbar.
- 23 fokussierte synthetische Tests in drei Dateien sowie Client- und Serverbuild sind erfolgreich. Persistenz/API, GPO, Verpflichtungshistorie, Score-A/B-Basis und UI bleiben offen.
- Projektweiter Lauf: 1.240 Tests bestanden, acht optionale PostgreSQL-Tests übersprungen. Derselbe fachfremde kombinierte Node-Importtest überschritt unter Gesamtlast einmal sein unverändertes Fünf-Sekunden-Limit und bestand isoliert mit 5/5 Tests in 3.661 ms. Local-only- und Designsprache-Prüfung sind erfolgreich; das Zeitlimit wurde nicht erhöht.

View File

@ -13,14 +13,16 @@ Policy Analyzer distinguishes differing configured values from absent settings.
| Outcome | Evidence required |
| --- | --- |
| Fail / Misconfigured | Explicit nonempty Microsoft recommendation, an actual configured customer value and a persisted deterministic mismatch. |
| Fail / Misconfigured | Explicit Microsoft recommendation and a persisted deterministic mismatch. A missing required key or typed empty value is a mismatch; ordinary missing value recommendations remain Not configured. |
| 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. |
| Pass | Explicit Microsoft recommendation and a persisted deterministic match. For Registry.pol deletion requirements, target absence in the analyzed backup set is an explicit matching state. |
| 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.
Registry.pol `delete-value`, `delete-all-values` and `create-key` are compared as typed target states rather than discarded as missing values. A typed empty string remains a present value. These rules describe only the imported backup configuration; local effective state remains a separate Policy Analyzer operation and requires separate evidence.
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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
/>
<link rel="icon" type="image/png" href="/assets/assessor-icon-transparent-CwoRGD4B.png" />
<title>ASSESSOR</title>
<script type="module" crossorigin src="/assets/index-574eOw2u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLrW8Yip.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
# Dummy: Prüfung von Policy Analyzer gegen ASSESSOR
Prüfdatum: 18.09.2026. Auftrag: korrektes Einlesen und Zuordnen ausschließlich am freigegebenen Client **Dummy** prüfen. Keine Änderungen an Anwendungslogik, GPOs, Datenbank oder Bewertungen. Einordnung: Diagnose im Bereich Phase 13B; keine Implementierung und kein abgeschlossener Meilenstein.
## Ergebnis
**Bei den tatsächlich vorhandenen Dummy-GPO-Werten wurde keine Fehlzuordnung und kein Auslesefehler gefunden.** Alle 51 Originalanweisungen aus drei GPO-Backups stimmen mit den gespeicherten Beobachtungen, den zugehörigen 50 Excel-Einstellungen und ihrer Quellenzuordnung in der Work-Projektion überein.
Das ist ausdrücklich keine uneingeschränkte Bestätigung vollständiger Policy-Analyzer-Parität. Zusätzlich wurden vier Auffälligkeiten belegt:
1. Ein identischer Registry-Zielwert wird je nach Quelldateiformat unter zwei technischen Identitäten geführt: `LdapEnforceChannelBinding`, Excel-Zeile 871. Das ist eine formatübergreifende Identitätslücke, keine Vertauschung vorhandener Dummy-Werte.
2. Bei score-neutralem `Not configured` können Beschreibung und Vergleichstext dennoch eine Abweichung beziehungsweise einen erforderlichen fehlenden Wert behaupten.
3. Der gespeicherte Vergleich enthält vier Baselinepakete jeweils mit zwei Analyseversionen. Das erzeugt 1.620 zusätzliche Vergleichszeilen, aber im geprüften Dummy-Stand keine veränderten Einstellungsurteile.
4. Fünf in Excel enthaltene Dienst-Einstellungen werden vom Baseline-Parser nicht unterstützt; betroffen sind 29 belegte Baseline-/Einstellungskombinationen. Dummy konfiguriert diese fünf Dienste nicht.
Der frühere Screenshot und andere Kundendaten sind nicht Bestandteil dieser Bewertung.
## 1. Prüfgrundlagen und Abgrenzung
- Originaldatei: [Output-PolAnal-Dummy.xlsx](<C:/Users/milu/OneDrive - Exxeta/OC_SOC - General/Projekt Assessor/Dummy-Scans/Output-PolAnal-Dummy.xlsx>), Blatt `Policy Analyzer`, Bereich `A1:BO953`: 952 Datenzeilen, 67 Spalten.
- Dummy-Werte: `G:I`; technische Adresse: `D:F`; Dummy-GPO-Namen und Quelldateien: `AR:AS`.
- Elf Microsoft-Baselinepakete: Wert-/Options-/Typblöcke `J:AP`, zugehörige Quellen `AT:BO`.
- Projekt: `Dummy`, ID `project-7cbcb7ad`.
- Datenspeicher: ausschließlich die kanonische lokale PostgreSQL-Datenbank `assessor` auf `127.0.0.1:5432`; Abfragen mit schreibgeschützter Transaktion beziehungsweise schreibgeschützter Verbindung und exaktem Dummy-Projektfilter. Gemeinsam verwendete Microsoft-Baselines wurden nur als Vergleichsquellen dieses Projekts gelesen.
- Original-Dummy-Archiv: `gpo-backups/dummy-gpo-folder-2026-07-22.zip`, 23.163 Bytes; SHA-256 `2d083c277c36ad357dec97a44b50323f12de8fe1875db2d7024089754397acb2`.
- Ausgewählter gespeicherter Vergleich: `policy-evaluation-run-84d8b95c8e7610994abb842a4de05e0e`, Engine `1.9.0`, abgeschlossen `2026-09-17T13:53:44.060Z`.
- Repository-Revision bei Beginn und Abschluss: `420`.
Die Prüfung umfasst importierte Backup-Konfiguration, keine tatsächlich wirksame Endgerätekonfiguration, keine GPO-Verknüpfungen oder Vererbungsreihenfolge und keine Aussage zur sachgerechten Auswahl einer Betriebssystem-/Rollenbaseline.
## 2. Methode
Die Prüfkette war: Excel → unverändertes Original-ZIP → unabhängig dekodierte Quelldateien → persistierte Dummy-Beobachtungen → ausgewählte Bewertung → produktive Work-/Reader-Datenprojektion.
`Registry.pol` wurde unabhängig als Binärformat gelesen, einschließlich Datentyp, Nutzdaten, Datensatzposition und Byteoffset. Die INF-Dateien wurden unabhängig nach Sektion, Schlüssel, Originalwert und Zeile ausgewertet. Anschließend wurden sämtliche Dummy-Beobachtungen einzeln dagegen geprüft. Die Work-Projektion wurde im Speicher mit den produktiven Projektionsfunktionen und den Dummy-API-Daten aufgebaut; die Anwendung wurde nicht neu bewertet oder verändert. Dies ist eine Prüfung der Datenprojektion, kein zusätzlicher visueller Browser-/CSS-Test.
Verglichen wurde anhand vollständiger technischer Adressen und exakter Quellen, nicht anhand ähnlich klingender Anzeigenamen. Groß-/Kleinschreibung von Registry-Adressen und Pfadseparatoren wurden für den Adressvergleich normalisiert; Wertinhalt und Typ blieben erhalten. Principal-Sammlungen wurden als Mengen geprüft; zusätzlich wurde die ursprüngliche Quellenreihenfolge gegenüber den gespeicherten Werten kontrolliert.
Wichtig beim Excel-Vergleich: Policy Analyzer fasst Wertlisten unabhängig von GPO-/Quelldateilisten zusammen. Mehrzeilige Wert- und Quellenzellen dürfen nicht positionsweise miteinander verbunden werden. Die Wert-/Typmengen und Quellenmengen wurden daher separat verglichen; die einzelne Quellenzuordnung wurde gegen die Originaldateien geprüft.
Die Excel-Zellen wurden mit einem zweiten, unabhängigen XLSX-Leser verifiziert. Leere Zeichenfolgen wurden nicht als Zahlen oder fehlende Registry-Schlüssel interpretiert. Der erste XLSX-Importweg zeigte bei leeren Shared Strings technische Indexwerte; dessen Ergebnis wurde verworfen, nicht als Prüfgrundlage verwendet.
## 3. Vollständigkeit und Zuordnung der Dummy-Werte
| Prüfschritt | Ergebnis |
|---|---:|
| GPO-Backups | 3 |
| Policy-Quelldateien | 4 |
| Originalanweisungen aus Registry.pol | 4 |
| Originalanweisungen aus Security-Template-INF | 47 |
| Originalanweisungen insgesamt | 51 |
| Exakt passende gespeicherte Beobachtungen | 51 von 51 |
| Excel-Einstellungen mit nichtleerem Datenwert | 48 |
| Zusätzlich explizite leere REG_NONE-Schlüsselanlagen | 2 |
| Unterschiedliche konfigurierte Dummy-Einstellungen | 50 |
| In Work wiedergefundene Originalbeobachtungen | 51 von 51 |
| Quellenbehaftete Work-Vergleichszeilen mit geprüfter Zuordnung | 339 von 339 |
| Falsche Werte, Typen, Quellen oder Backup-Zuordnungen | 0 |
Geprüft wurden insbesondere Originaldatei-SHA, vollständiger Schlüssel, Wertname, Typ, Daten, Projekt-/Backup-Zuordnung und die jeweiligen INF-Zeilen beziehungsweise Registry.pol-Datensatzpositionen. Auch die in Excel angegebenen GPO-Namen wurden gegen die Originalmetadaten geprüft.
### Warum 51 Anweisungen nur 50 Einstellungen ergeben
`LDAPServerIntegrity` in Excel-Zeile **873** wird in zwei Dummy-GPOs auf denselben DWORD-Wert **2** gesetzt: `Default Domain Controllers Policy` und `LDqp`. ASSESSOR erhält beide Quellen und kennzeichnet die identische Mehrfachkonfiguration als Duplicate. Das ist kein Konflikt und kein verlorener Datensatz. Maßgebliche Excel-Zellen: `D873:I873` und `AR873:AS873`.
### Vollständige Liste der geprüften Dummy-Einstellungen
Für jede nachfolgende Zeile sind Originalquelle, Excel, gespeicherter Wert und Work-Quellenzuordnung bestätigt. „Menge identisch“ bedeutet vollständigen SID-Mengenvergleich; keine Stichprobe.
| Excel-Zeile | Technischer Wert / Operation | Bestätigter Dummy-Wert |
|---:|---|---|
| 400 | EFSBlob | REG_BINARY, vollständig bytegleich |
| 401 | EFS-Zertifikat / Blob | REG_BINARY, vollständig bytegleich |
| 402 | EFS / CRLs: Schlüsselanlage | REG_NONE, explizit leer |
| 403 | EFS / CTLs: Schlüsselanlage | REG_NONE, explizit leer |
| 429 | RequireLogonToChangePassword | 0 |
| 845 | PasswordComplexity | 0 |
| 846 | PasswordHistorySize | 24 |
| 847 | ClearTextPassword | 0 |
| 848 | MaximumPasswordAge | 37201 |
| 849 | MinimumPasswordLength | 5 |
| 850 | MinimumPasswordAge | 1 |
| 851 | TicketValidateClient | 1 |
| 852 | MaxTicketAge | 10 |
| 853 | MaxServiceAge | 600 |
| 854 | MaxClockSkew | 5 |
| 855 | MaxRenewAge | 7 |
| 856 | LockoutBadCount | 5 |
| 857 | LockoutDuration | 5 |
| 858 | ResetLockoutCount | 5 |
| 859 | NoLMHash | DWORD 1 |
| 873 | LDAPServerIntegrity | DWORD 2, zwei Originalquellen |
| 877 | RequireSignOrSeal | DWORD 1 |
| 888 | RequireSecuritySignature | DWORD 1 |
| 889 | EnableSecuritySignature | DWORD 1 |
| 891 | ForceLogoffWhenHourExpire | 0 |
| 904 | LSAAnonymousNameLookup | 0 |
| 912 | SeSystemTimePrivilege | Menge identisch |
| 913 | SeIncreaseBasePriorityPrivilege | Menge identisch |
| 914 | SeBatchLogonRight | Menge identisch |
| 918 | SeIncreaseQuotaPrivilege | Menge identisch |
| 920 | SeNetworkLogonRight | Menge identisch |
| 921 | SeChangeNotifyPrivilege | Menge identisch |
| 922 | SeDebugPrivilege | Menge identisch |
| 925 | SeUndockPrivilege | Menge identisch |
| 926 | SeEnableDelegationPrivilege | Menge identisch |
| 927 | SeAssignPrimaryTokenPrivilege | Menge identisch |
| 928 | SeCreatePagefilePrivilege | Menge identisch |
| 929 | SeSystemProfilePrivilege | Menge identisch |
| 930 | SeProfileSingleProcessPrivilege | Menge identisch |
| 935 | SeRemoteShutdownPrivilege | Menge identisch |
| 936 | SeAuditPrivilege | Menge identisch |
| 937 | SeShutdownPrivilege | Menge identisch |
| 938 | SeMachineAccountPrivilege | Menge identisch |
| 939 | SeLoadDriverPrivilege | Menge identisch |
| 941 | SeInteractiveLogonRight | Menge identisch |
| 942 | SeBackupPrivilege | Menge identisch |
| 944 | SeTakeOwnershipPrivilege | Menge identisch |
| 945 | SeSystemEnvironmentPrivilege | Menge identisch |
| 946 | SeSecurityPrivilege | Menge identisch |
| 947 | SeRestorePrivilege | Menge identisch |
Die produktive Verdichtungsfunktion liefert für diese **50 quellenbehafteten Einstellungen**: **16 Fail, 15 Pass, 19 No baseline rule**, zusätzlich **eine Duplicate-Markierung und keine Konfliktmarkierung**. Diese Zählung bestätigt die technische Zusammenführung; sie ist keine unabhängige Freigabe der fachlichen Baseline-Auswahl.
## 4. Abgleich der Baseline-Seite der Excel
Alle elf referenzierten Baseline-Archive wurden gegen ihre gespeicherte Paket-SHA geprüft. Für 5.031 aktuelle Baseline-Setting-Referenzen wurde die SHA der zugrunde liegenden 122 Policy-Dateien gegen die Originalarchive verifiziert.
| Baseline | Belegte Nicht-Audit-Kombinationen | Strikt passend | Nur Optionsreihenfolge | Nicht unterstützte Dienste | Audit-Kombinationen passend |
|---|---:|---:|---:|---:|---:|
| Microsoft 365 Apps 2512 | 385 | 385 | 0 | 0 | 0 |
| Microsoft Edge 139 | 20 | 20 | 0 | 0 | 0 |
| Windows 10 1809 / Server 2019 | 366 | 359 | 2 | 5 | 27 |
| Windows 11 23H2 | 369 | 363 | 2 | 4 | 23 |
| Windows 11 24H2 | 403 | 397 | 2 | 4 | 23 |
| Windows 11 25H2 | 402 | 396 | 2 | 4 | 23 |
| Windows Server 2025 | 360 | 357 | 2 | 1 | 30 |
| Windows Server 2022 | 309 | 306 | 2 | 1 | 29 |
| Windows 10 1607 / Server 2016 | 356 | 353 | 2 | 1 | 24 |
| Windows 10 22H2 | 343 | 337 | 2 | 4 | 23 |
| Windows 10 20H2 / Server 20H2 | 357 | 350 | 2 | 5 | 29 |
| **Summe** | **3.670** | **3.623** | **18** | **29** | **231** |
Eine Kombination bezeichnet hier eine belegte Einstellung in einem Baselinepaket, nicht jede einzelne Zelle und nicht jede enthaltene GPO-Variante.
Die 32 unterschiedlichen Audit-Policy-Zeilen `813:844` umfassen 231 belegte Baseline-Kombinationen: alle stimmen hinsichtlich Erfolgs-/Fehlerflags und Quelldateien überein. Zusätzlich wurden die gespeicherten Audit-GUIDs, Flags und Quellenzeilen unmittelbar gegen die originalen `audit.csv`-Dateien geprüft. Die deutschen Anzeigenamen wurden mit dem lokalen, nur lesend abgefragten Audit-Unterkategorienkatalog und vier dokumentierten sprachlichen Aliaszuordnungen auf GUIDs abgebildet; es wurden keine Audit-Einstellungen des Rechners verändert.
Die 18 reinen Darstellungsunterschiede liegen in den HardenedPaths-Zeilen **507 und 508**. Excel schreibt `RequireIntegrity=1,RequireMutualAuthentication=1`; ASSESSOR erhält `RequireMutualAuthentication=1,RequireIntegrity=1`. Adresse, Quellen und enthaltene Optionen sind gleich; lediglich die Reihenfolge unterscheidet sich. Das ist keine Vertauschung der Einstellung. Die Aussage bezieht sich auf diese konkrete Optionsliste und rechtfertigt keine allgemeine Sortierung beliebiger Registry-Strings.
## 5. Bestätigte Auffälligkeiten
### A. Eine Registry-Adresse, zwei technische Identitäten
Excel-Zeile **871** führt `HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters\LdapEnforceChannelBinding` einmal. ASSESSOR führt für denselben Zielwert eine `registry-value`-Identität aus Registry.pol und eine `security-template`-Identität aus dem INF-Abschnitt `Registry Values`.
Ursache: [policy-target-resolver.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/server/gpo/policy-target-resolver.ts:8>) leitet nur `registry-policy` an den Registry-Resolver weiter; die INF-Identität wird danach mit eigenem Mechanismus/Sektionsschlüssel gebildet. [admx-policy-catalog.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/server/gpo/admx-policy-catalog.ts:344>) bildet hingegen die Registry-Identität aus Scope, Zielart, Hive, vollständigem Schlüssel und Wertname.
Nachweisbarer Effekt: **948 ASSESSOR-Identitäten entsprechen 947 unterschiedlichen Excel-Adressen**. Die übrigen fünf der 952 Excel-Zeilen sind die nicht unterstützten Dienste. Genau eine Adresse ist doppelt identifiziert: Zeile 871.
Für Dummy ist `G871:I871` leer; alle fünf betroffenen gespeicherten Vergleiche stehen auf `Not configured` und haben keine Dummy-Quelle. Damit ist hier eine fehlende formatübergreifende Gleichsetzung belegt, aber kein dadurch falsch zugeordneter konfigurierter Dummy-Wert. Ein allgemeiner Gleichwertigkeitstest von INF und Registry.pol ist im Vergleichsvertrag bereits als offene Paritätsprüfung benannt.
### B. Widersprüchlicher Text bei Not configured
Reproduzierbares Dummy-Beispiel: Excel-Zeile **529**, `ACSettingIndex` unter PowerSettings-GUID `0e796bdb-100d-47d6-a2d5-f7d2daa51f51`. Dummy enthält dafür keinen Wert. ASSESSOR projiziert korrekt `Status = Not configured`, `Configured Value = Not configured` und keine Quellen.
Gleichzeitig lautet `Comparison` beziehungsweise der Beschreibungstext `Differs from every assessed baseline variant`; eine Variantenbeschreibung nennt `Missing required value or key`, und der Remediation-Text gibt einen Sollwert an. Damit wirkt eine score-neutrale Abwesenheit sprachlich wie eine konfigurierte Fehlstellung.
Ursache: [policy-observations-to-dataset.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/src/features/project-import/policy-observations-to-dataset.ts:91>) verwendet die Varianten-Zusammenfassung vor dem späteren Missing-Zweig. [guidance.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/src/profiles/group-policy/guidance.ts:81>) formuliert fehlende Werte pauschal als erforderlich. Die korrekte Abgrenzung normaler Abwesenheit ist dagegen bereits in [policy-comparison-disposition.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/src/domain/policy-comparison-disposition.ts:6>) vorhanden.
Dies ist ein belegter Darstellungs-/Semantikfehler, kein falsch eingelesener Istwert. Explizite Schlüsselpräsenz- und Leerstring-Anforderungen sind hiervon gesondert zu behandeln; ihre dokumentierten Vergleichsregeln dürfen durch eine spätere Korrektur nicht verändert werden.
### C. Doppelte Baseline-Analysegenerationen im gespeicherten Lauf
Der aktuelle gespeicherte Dummy-Lauf referenziert 15 Analysen zu elf Paketen. Vier Pakete sind gleichzeitig mit Analyzer `1.2.0` und `1.3.0` enthalten, jeweils mit identischer Baseline-ID und identischer Paket-SHA.
| Paket | Redundante Vergleiche |
|---|---:|
| Windows 11 23H2 | 388 |
| Windows 11 24H2 | 422 |
| Windows 11 25H2 | 421 |
| Windows Server 2025 | 389 |
| **Summe** | **1.620** |
Die paarweise Prüfung ergab null Unterschiede bei technischem Ziel, Ist-/Sollwerten, Klassifikation und Quellen. Eine ausschließlich im Speicher vorgenommene Bereinigung würde die Bewertungszeilen von 5.492 auf 3.872 und die Work-Vergleichszeilen von 5.511 auf 3.891 reduzieren. **Alle 948 verdichteten Einstellungsurteile bleiben dabei unverändert.**
Die laufende Status-API meldet dafür bereits `evaluationUpdates`, Grund `baseline-analysis`, mit elf Zielanalysen. Work zeigt den gespeicherten Lauf, bereinigt dessen innere historischen Referenzen aber nicht beim Lesen. Es wurde kein neuer Bewertungslauf gestartet.
### D. Nicht unterstützte Dienst-Einstellungen
Excel-Zeilen **949 bis 953** enthalten `AppIDSvc`, `XboxGipSvc`, `XblAuthManager`, `XboxNetApiSvc` und `XblGameSave` aus dem INF-Abschnitt `Service General Setting`. Insgesamt sind 29 Baseline-/Einstellungskombinationen belegt und in den Original-INF-Dateien vorhanden.
[security-template-parser.ts](<C:/Users/milu/OneDrive - Exxeta/Dokumente/VS CODE/ASSESSOR-2-5/server/gpo/security-template-parser.ts:13>) unterstützt nur `System Access`, `Kerberos Policy`, `Privilege Rights` und `Registry Values`. Andere Abschnitte werden mit `gpo-security-template-section-unsupported` diagnostiziert und nicht als vergleichbare Settings ausgegeben.
Das ist eine echte Abdeckungslücke gegenüber Excel, aber keine falsche Zuordnung. `G949:I953` ist für Dummy leer: Es ging dadurch kein in Dummy konfigurierter Dienstwert verloren. Die Diagnose erlaubt keine Aussage, dass nicht unterstützte Konfiguration allgemein fehlerfrei bewertet würde.
## 6. Integrität und mögliche nächste Schritte
Die Datenbankrevision blieb `420`; keine Re-Evaluation, kein Import und keine Änderung der Original-GPO-Dateien wurden ausgeführt. Vorhandene Änderungen im Arbeitsverzeichnis blieben unangetastet. Die 25 temporären Datenbankauszüge, Originalkopien und Prüfscripte wurden anschließend entfernt; erhalten bleibt dieser Bericht. Die Originale sind unverändert verfügbar; die temporären Kopien wurden nicht als dauerhafte Prüfartefakte aufbewahrt.
Der erste XLSX-Snapshot hatte SHA-256 `b3349781e8c7555ac2edcf5ecec9bd9b15c999e5213ca9112c7069643c7cf308`; beim abschließenden Gegenlesen hatte der Dateicontainer SHA-256 `4001bc5c6347c28b5677ed9235c2d015305aea5b95ccd12b6b917d20440076fb`. Der erneute vollständige Zellvergleich ergab **null geänderte Zellinhalte**. Im ZIP wurden zusätzliche Custom-XML-/Dokumentmetadaten und geänderte Paketbeziehungen festgestellt; die Tabellenbestandteile blieben identisch. ASSESSOR und diese Prüfung haben die Arbeitsmappe nicht beschrieben.
Sinnvolle, separat zu beauftragende Änderungen sind: erstens die formatübergreifende Registry-Identität mit synthetischem INF-/Registry.pol-End-to-End-Test schließen; zweitens Not-configured-Beschreibung und Remediation konsistent zum autoritativen Status machen; drittens veraltete Baseline-Analysegenerationen über den vorgesehenen Updatepfad behandeln. Die Unterstützung der Dienstsektion ist eine eigene Parsererweiterung. Keine dieser Änderungen wurde im Rahmen dieser Diagnose umgesetzt.

View File

@ -0,0 +1,77 @@
# Dummy: Result DB / Work count reconciliation
Read-only snapshot captured at 2026-09-18T12:11:13.513Z. Only project `project-7cbcb7ad`, verified as `Dummy`, was queried. No application or database changes were made. The companion `dummy-result-count-audit.mjs` uses scoped GET requests and reconstructs Work through the actual production selectors, projection and status filter.
## Result
| Measure | Screenshot and reproduced output | Correct current population under existing rules |
| --- | ---: | ---: |
| Result DB GPO deviation comparisons | 179 | 127 |
| Work unique settings with Fail verdict | 16 selected by Deviation | 25 |
The two corrected numbers are not meant to be equal: 127 baseline-package comparisons belong to 25 unique technical policy identities. Both stored runs contain the same 25 failing identities and the same 127 distinct identity/baseline-package pairs. The issues are therefore not explained by different granularity alone.
## 1. Result DB selects an older run with repeated baseline versions
Both runs use aggregation `policy-snapshot-aggregation-d0bd18c02cbc8d8e4102391f089c4c7f` and evaluator 1.9.0.
| Property | Result DB selection | Work selection |
| --- | --- | --- |
| Run | policy-evaluation-run-84d8b95c8e7610994abb842a4de05e0e | policy-evaluation-run-ac3189bde3afae087102bc4e77ad9cba |
| Completed (UTC) | 2026-09-17T13:53:44.060Z | 2026-09-18T11:24:05.072Z |
| Baseline analysis references | 15 | 11 |
| Distinct baseline packages | 11 | 11 |
| Evaluation results | 5492 | 3872 |
| Fail comparisons | 179 | 127 |
The PostgreSQL selector in `server/postgres/postgres-assessor-repository.ts:1101` ranks raw `baselineAnalysisIds` count before completion time. Fifteen references therefore outrank eleven, even though both cover eleven packages. Work's selector in `src/domain/unified-result.ts:599` measures distinct compared `baselineId` values instead and selects the newer run.
The older run contains repeated analysis versions for Windows 11 23H2, 24H2, 25H2 and Windows Server 2025. Deduplicating its Fail comparisons by technical identity plus baseline package yields exactly the newer run's 127 pairs: zero old-only and zero new-only pairs. The excess is exactly 52 comparisons.
Result DB returned 5493 GPO results: 179 misconfigured, 227 match, 5080 not-configured, 6 review-required, and 1 duplicate diagnostic. The extra diagnostic explains the difference from 5492 evaluation results. All 179 deviations belong to Dummy assessment `assessment-batch-1785225371107-78`.
## 2. Work suppresses nine authoritative Fail verdicts
Reconstructed Work contains 948 records: 25 Fail, 25 Pass, 879 Not configured and 19 No baseline rule. Running the production Deviation filter returns only 16.
The guard in `src/profiles/profile-factory.ts:67` rejects a record whenever its displayed Configured Value equals Not configured, even when its authoritative status is Fail. This contradicts the rule that status filters follow the authoritative verdict and the explicit required-registry-presence exception.
The excluded records are eight required key-presence states and one required present empty string. Each retained comparison is independently Different and its retained baseline variant assessment is all-fail. Missing required presence is Fail under the binding rules; ordinary missing recommendations remain score-neutral Not configured.
| Excluded setting | Current Fail comparisons |
| --- | ---: |
| Software\Policies\Microsoft\Windows\SrpV2\Dll | 3 |
| Software\Policies\Microsoft\Windows\SrpV2\Msi | 3 |
| Software\Policies\Microsoft\Windows\SrpV2\Script | 3 |
| Software\Policies\Microsoft\WindowsFirewall\PublicProfile\Logging | 1 |
| fbaenabledhosts (present empty string, Microsoft 365 Apps 2512) | 1 |
| Software\Policies\Microsoft\Windows\Safer | 5 |
| Software\Policies\Microsoft\WindowsFirewall\PrivateProfile\Logging | 1 |
| Software\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig | 1 |
| Software\Policies\Microsoft\WindowsFirewall\DomainProfile\Logging | 1 |
These nine settings account for 19 comparisons. The 16 visible failing settings account for the other 108 comparisons, totaling 127.
The exact screenshot filter counts were reproduced: Deviation 16, Passed 25, Not configured 879, No baseline rule 19, Not comparable 0, Conflicts 0, Duplicates 1. Diagnostic categories overlap verdicts and must not be summed as exclusive status buckets.
## Repair scope identified, not implemented
Align PostgreSQL current-run selection with the shared distinct-baseline-coverage rule, and remove the display-value override of authoritative GPO Fail verdicts. Preserve ordinary Not configured neutrality, diagnostic overlap and historical evaluations. Permanent synthetic behavioral tests must cover current-run selection with repeated baseline versions and both GPO Work entry paths for a missing required key/empty-string presence.
## Follow-up: authorized fix and verification
The user subsequently authorized implementation. The diagnosis above is preserved as the pre-fix snapshot; the following is the post-fix acceptance, captured at **2026-09-18T12:40:30.253Z**.
- PostgreSQL now counts distinct nonempty compared baseline IDs, falls back to declared analysis references only when no compared baseline is named, and retains completion time / run ID ordering. No evaluation record was edited or removed.
- Work Deviation now follows the authoritative Fail verdict without a Configured Value text override. Ordinary Not configured, historical readiness and diagnostic flags retain their existing behavior.
- Both views select `policy-evaluation-run-ac3189bde3afae087102bc4e77ad9cba` for the same Dummy aggregation and assessment.
- Result DB: **127 Fail comparisons**, **25 distinct technical identities**, 3873 total results (3872 evaluations plus one duplicate diagnostic). Classification counts: misconfigured 127, match 162, not-configured 3578, review-required 5, duplicate 1.
- Work: **25 Deviation**, 25 Passed, 879 Not configured, 19 No baseline rule, 0 Not comparable, 0 Conflicts, 1 Duplicates. Total 948 rows.
- Identity sets are exactly equal in both directions. There are 127 distinct identity/package comparison pairs, no additional pairs and **zero hidden Fail settings**.
- The running backend reports PostgreSQL and release `3e9d353e9e4d5e96d395252edbc02a4826b55075ab155b3d094954e27ae55c90`, matching the SHA-256 of the built server bundle containing the corrected selection SQL. The existing managed development process applied the update; no alternate datastore was started.
Verification: 50 targeted tests passed, including five actual PostgreSQL executions of the repository's production SELECT over fully synthetic CTE relations on a read-only connection. Cases cover repeated versions, broader versus newer coverage, deterministic ties, unavailable baseline-ID fallback and empty-result fallback, plus foreign project and independent snapshot scope. Permanent Work interaction tests cover both entry paths and required presence versus ordinary absence and history; Result DB interaction checks current comparison counts and filtering. Client and server production builds, local-only security and design checks passed.
The initial full suite reported 1357 passed, two failures and 13 optional skips, including a transient schema-file read/RPC timeout during an approximately six-minute runtime interruption. The schema test passed immediately on isolated rerun. The unrelated `gpo-xml-presentation` assertion remains reproducible: it forbids any code tag in the entire Reader, including the existing Policy Analyzer search-key control. The configured/expected XML value blocks remain inert text. Neither that Reader feature nor its test was changed for this fix.
The final full-suite rerun completed in 97.73 seconds: **1358 passed, one failed, 13 optional tests skipped**, with no timeout or unhandled RPC error. Only the unchanged XML-Reader assertion above fails. Five of the optional PostgreSQL cases are the new read-only parity checks, all separately executed successfully among the 50 targeted tests. The remaining eight are existing opt-in mutating integration tests and were not run against the canonical populated database. Local-only and design checks were run separately and passed because the full-suite command stops at the unrelated failed assertion.

View File

@ -0,0 +1,88 @@
// Read-only reconciliation. Every live request is scoped to the explicitly authorized Dummy project.
import { createServer } from 'vite';
const projectId = 'project-7cbcb7ad';
const base = 'http://127.0.0.1:4173/api/v1';
const requests = [];
async function get(path) {
requests.push(path);
const response = await fetch(base + path, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error(`Read failed: ${response.status} ${path}`);
return response.json();
}
const project = await get(`/projects/${projectId}`);
if (project.name !== 'Dummy') throw new Error('Refusing a project other than Dummy');
const [evaluations, overview, resultRows, observations] = await Promise.all([
get(`/projects/${projectId}/policy-evaluations`),
get(`/projects/${projectId}/policy-analysis-overview`),
get(`/results?projectId=${projectId}&sourceTool=GPOs&limit=10000`),
get(`/results?projectId=${projectId}&sourceKind=policy-observation&audience=internal-analysis&limit=10000`)
]);
if (resultRows.total !== resultRows.items.length || observations.total !== observations.items.length) throw new Error('Incomplete page');
const counts = (items, key) => Object.fromEntries([...Map.groupBy(items, key)].map(([value, group]) => [value, group.length]));
const vite = await createServer({ configFile: false, appType: 'custom', server: { middlewareMode: true, hmr: false, watch: null } });
try {
const { currentPolicyEvaluationsForWork } = await vite.ssrLoadModule('/src/features/project-import/load-canonical-project-workspace.ts');
const { appendPolicyObservationsToDataset } = await vite.ssrLoadModule('/src/features/project-import/policy-observations-to-dataset.ts');
const { createPolicyEvaluationUnifiedResult } = await vite.ssrLoadModule('/src/domain/unified-result.ts');
const { genericProfile } = await vite.ssrLoadModule('/src/profiles/generic/generic-profile.ts');
const { filterAndSortRecords } = await vite.ssrLoadModule('/src/features/search-filter/filter-records.ts');
const workRuns = currentPolicyEvaluationsForWork(evaluations.records,
new Set(observations.items.map(item => item.sourceResultId)), new Set(overview.current.evaluationRunIds));
const dataset = appendPolicyObservationsToDataset(null, 'Dummy', observations.items, workRuns);
const workRecords = dataset.sheets[0].analysis.records;
const field = (record, label) => record.fields.find(item => item.label === label)?.value ?? '';
const dbFails = resultRows.items.filter(item => item.isDeviation);
const workFails = workRecords.filter(item => field(item, 'Status') === 'Fail');
const filteredWork = filterAndSortRecords(dataset.sheets[0], { activeProfile: genericProfile, dataset,
search: '', sortColumn: 'all', sortDirection: 'asc', statusFilter: 'deviation', facetFilters: {}, actionOnly: false, reviews: {} });
const hiddenFails = workFails.filter(item => !filteredWork.includes(item));
const failsByIdentity = Map.groupBy(dbFails, item => item.policyIdentityKey);
const workFailIds = new Set(workFails.map(item => field(item, 'Technical Identity')));
const unique = items => [...new Set(items)];
const pair = item => JSON.stringify([item.identity?.id ?? item.policyIdentityKey, item.baselineId ?? item.policyBaselineId]);
const newFails = workRuns.flatMap(record => record.results.filter(item => createPolicyEvaluationUnifiedResult(record.run, item).isDeviation));
const oldPairs = new Set(dbFails.map(pair));
const newPairs = new Set(newFails.map(pair));
const summaryRun = record => ({ id: record.run.id, aggregation: record.run.aggregationRunId,
engine: record.run.evaluator.version, completedAt: record.run.completedAt, results: record.results.length,
analysisCount: record.run.baselineAnalysisIds.length,
packages: unique(record.results.map(item => item.baselineId)).length,
failComparisons: record.results.filter(item => createPolicyEvaluationUnifiedResult(record.run, item).isDeviation).length });
console.log(JSON.stringify({ capturedAt: new Date().toISOString(), projectId,
observationCount: observations.total, observationAssessments: counts(observations.items, item => item.assessmentId),
resultDb: { total: resultRows.total, classifications: counts(resultRows.items, item => item.policyClassification),
failRows: dbFails.length, failIdentities: failsByIdentity.size,
failRuns: counts(dbFails, item => item.evaluationRunId), failAssessments: counts(dbFails, item => item.assessmentId) },
work: { rows: workRecords.length, statuses: counts(workRecords, item => field(item, 'Status')),
actualDeviationFilterCount: filteredWork.length,
actualStatusFilterCounts: Object.fromEntries(['deviation', 'passed', 'not-configured', 'no-baseline-rule', 'not-comparable', 'conflicts', 'duplicates']
.map(status => [status, workRecords.filter(record => genericProfile.matchesStatus(record, status)).length])),
conflicts: workRecords.filter(item => field(item, 'Conflict')).length,
duplicates: workRecords.filter(item => field(item, 'Duplicates')).length,
selectedRuns: workRuns.map(summaryRun) },
relevantRuns: evaluations.records.filter(record => workRuns.some(current => current.run.aggregationRunId === record.run.aggregationRunId)).map(summaryRun),
reconciliation: { missingFromWork: unique(dbFails.filter(item => !workFailIds.has(item.policyIdentityKey)).map(item => item.policyIdentityKey)),
missingFromResultDb: [...workFailIds].filter(id => !failsByIdentity.has(id)),
uniqueOldFailPairs: oldPairs.size, uniqueNewFailPairs: newPairs.size,
excessOldFailComparisons: dbFails.length - oldPairs.size,
oldOnlyPairs: [...oldPairs].filter(key => !newPairs.has(key)), newOnlyPairs: [...newPairs].filter(key => !oldPairs.has(key)),
hiddenFailSettings: hiddenFails.map(record => ({ title: record.title, status: field(record, 'Status'), configured: field(record, 'Configured Value'),
identity: field(record, 'Technical Identity'),
comparisons: newFails.filter(item => item.identity.id === field(record, 'Technical Identity')).map(item => ({
baseline: item.baselineLabel, customerState: item.customerState, classification: item.classification,
baselineValue: item.comparator.baseline, decision: item.baselineVariantAssessment?.decision,
variants: item.baselineVariantAssessment?.variants.map(variant => ({ expected: variant.comparator.baseline, relation: variant.comparator.relation })) })) })),
settings: workFails.map(record => {
const identity = field(record, 'Technical Identity');
const rows = failsByIdentity.get(identity) ?? [];
const comparisons = evaluations.records.flatMap(evaluation => evaluation.results
.filter(item => rows.some(row => row.sourceResultId === item.id)));
const combinations = Map.groupBy(comparisons, item => item.baselineId);
return { title: record.title, identity, resultDbRows: rows.length,
distinctPackages: unique(rows.map(item => item.policyBaselineId)).length,
repeatedPackages: [...combinations].filter(([, items]) => items.length > 1).map(([, items]) => ({
baseline: items[0].baselineLabel, comparisons: items.length,
analysisVersions: unique(items.map(item => item.baselineAnalysisId)).length })) };
}) }, requests }, null, 2));
} finally { await vite.close(); }

View File

@ -0,0 +1,63 @@
# Abnahme: aktueller Work-Ansichtsexport
Stand: 18.09.2026. Ausschließlich der ausdrücklich freigegebene **Dummy** wurde
über GET-Aufrufe gelesen. Keine Änderung an Kundendaten, GPOs, Reviews,
Auswertungen oder Datenbank. Keine Dummy-Inhalte als Testfixtures gespeichert.
## Umsetzung
- CSV, MD und HTML verwenden dieselbe eingefrorene Ansicht und Sortierung.
- Genau zwei Inhaltsspalten: **Soll-Zustand** und **Abweichungen**.
- Soll-Zustand enthält Regel, erwartete Werte, native Behebung, Prüfhinweise und
Scope. Abweichungen enthält tatsächlichen Status, Werte, Quellen und Results.
- Native GPO-Anweisungen, Backup-Identitäten, Reihenfolge innerhalb der Quelle,
Baseline-Varianten und Typinformation bleiben nachvollziehbar. Kein erfundener
einheitlicher Sollwert, keine Aussage zur wirksamen Endpoint-Konfiguration.
- Nicht konfigurierte oder nicht vergleichbare Empfehlungen bleiben neutral.
Review „Done“ und Risikoakzeptanz ersetzen keinen Rescan-Nachweis.
- Fehlende Belege und fehlende separate Lösungstexte werden benannt. Bei Maester
bleiben eingebettete Remediation-Texte vollständig erhalten.
- HTML: offline, skriptfrei, eingebettetes Inter, Umbruch statt Abschneiden.
- CSV: genau zwei Spalten; große Inhalte bleiben durch beschriftete
Fortsetzungszeilen unter derselben Finding-ID vollständig erhalten.
## Vollständigkeitsabgleich
| Dummy-Auswahl | Work-Treffer | Results-Zeilen | Geprüfte Results-Zellen | Results-Textabschnitte | GPO-Observations |
| --- | ---: | ---: | ---: | ---: | ---: |
| Deviation | 477 | 7.599 | 20.434 | 395 | 16 |
| All | 1.833 | 9.917 | 27.059 | 708 | 51 |
Die geladenen Work-Daten enthalten 119 Purple-Knight-, 411 CIS-CAT-, 318
Maester-, 37 PingCastle- und 948 GPO-Einträge. Dies ist der gesamte Dummy-Scope,
nicht die Zusage identischer Zahlen für einen einzelnen Timeslot oder Filter.
Jede exportierte Finding-ID und ihre Reihenfolge wurden gegen die Work-Auswahl
geprüft. Jede verknüpfte Result-Zeile und Zelle sowie alle Results-Textabschnitte
und GPO-Quellen wurden abgeglichen. Die öffentliche Baseline-Dokumentation wurde
über denselben Workspace-Loader wie in Work berücksichtigt.
Keine fehlenden Result-Referenzen im geprüften Dummy. CSV wurde wieder eingelesen
und zellengenau abgeglichen: keine Datenverluste, keine Zellen über 32.767 Zeichen.
Zehn zusätzliche Fortsetzungszeilen für Deviation, elf für All; sie zählen nicht
als zusätzliche Findings. HTML und MD behalten eine Einheit pro Work-Eintrag.
## Technische Abnahme
53 fokussierte Export-/Results-Tests bestanden. Nach finaler Schriftintegration
nochmals 16 neue Export-/UI-Tests bestanden. Enthalten sind Filter, Suche,
Sortierung, 90 Treffer unabhängig vom Viewport, ignorierte Fremdmarkierungen,
aktives My Workspace, Nulltreffer, Results, Quellattribution, neutrale GPO-Urteile,
Review/Rescan-Trennung, CSV-Langzellen, Teilbelege, inert gerendertes Markup und
Fehler ohne Teil-Download.
Gesamtsuite: 1.412 bestanden, 13 optionale PostgreSQL-Tests übersprungen;
ein bestehender, unabhängiger Fehler in `gpo-xml-presentation.test.tsx:70`
(globale Annahme „kein code-Element“ im Reader). Unverändert offengehalten.
Client-/Server-Build, Local-only- und Designprüfung bestanden. App HTTP 200,
Backend `ok`. Synthetische Browserprüfung bei normaler Breite und 390 px:
kein horizontaler Überhang, vollständiges Dokumentende sichtbar.
Reproduzierbarer, lesender Opt-in-Abgleich:
`node outputs/dummy-work-export-audit.mjs --verify`.

View File

@ -0,0 +1,49 @@
// Opt-in, read-only check of the explicitly authorized Dummy workspace.
// No source content is persisted; all HTTP calls are GET and project-scoped.
import { createServer } from 'vite';
const projectId = 'project-7cbcb7ad';
const base = 'http://127.0.0.1:4173/api/v1';
const get = async path => {
const response = await fetch(base + path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
};
if ((await get(`/projects/${projectId}`)).name !== 'Dummy') throw new Error('Not Dummy');
const assessments = await get(`/projects/${projectId}/assessments`);
const allowed = new Set(assessments.map(item => item.id));
const reader = {
listProjectAssessments: async id => { if (id !== projectId) throw new Error('Wrong scope'); return assessments; },
loadAssessmentModel: async id => {
if (!allowed.has(id)) throw new Error('Wrong assessment');
const model = await get(`/assessments/${encodeURIComponent(id)}/model`);
if (model.project.id !== projectId) throw new Error('Wrong model');
return model;
},
queryUnifiedResults: async query => {
if (query.projectId !== projectId) throw new Error('Wrong result scope');
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, Array.isArray(value) ? value.join(',') : String(value));
return get(`/results?${params}`);
},
listPolicyEvaluations: () => get(`/projects/${projectId}/policy-evaluations`),
getPolicyAnalysisOverview: () => get(`/projects/${projectId}/policy-analysis-overview`),
// Public reference data, only IDs actually referenced by Dummy's evaluations.
listMicrosoftSecurityBaselineAnalyses: id => get(`/reference-data/microsoft-security-baselines/${encodeURIComponent(id)}/analysis`)
};
const vite = await createServer({ configFile: false, appType: 'custom', server: { middlewareMode: true, hmr: false, watch: null } });
try {
const { loadCanonicalProjectWorkspace } = await vite.ssrLoadModule('/src/features/project-import/load-canonical-project-workspace.ts');
const { toolIdForRecord } = await vite.ssrLoadModule('/src/importers/status-normalization.ts');
const { dataset } = await loadCanonicalProjectWorkspace(reader, projectId);
const sheet = dataset.sheets.find(item => item.isProjectSheet) ?? dataset.sheets[0];
const groups = Map.groupBy(sheet.analysis.records, record => toolIdForRecord(record, sheet.toolId ?? dataset.profileId));
console.log(JSON.stringify({ assessments: assessments.length, sheets: dataset.sheets.length,
groups: [...groups].map(([tool, records]) => ({ tool, count: records.length,
fields: process.argv.includes('--inspect') ? [...new Set(records.flatMap(record => record.fields.filter(field => field.value).map(field => field.label)))] : undefined,
sample: process.argv.includes('--inspect') && tool !== 'group-policy' ? records.find(record => record.fields.some(field => /^(Status|Result)$/.test(field.label) && /fail/i.test(field.value)))?.fields.filter(field => /^(Description|Remediation|Advised solution|Result|Result message|Details|Results|Notes)$/.test(field.label) && field.value).map(field => ({ label: field.label, value: field.value.slice(0, 1300) })) : undefined
})) }, null, 2));
if (process.argv.includes('--verify')) {
const { verify } = await import('./dummy-work-export-verification.mjs');
await verify(vite, dataset, sheet);
}
} finally { await vite.close(); }

View File

@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
export async function verify(vite, dataset, sheet) {
const exports = await vite.ssrLoadModule('/src/exports/work-view-export.ts');
const { genericProfile } = await vite.ssrLoadModule('/src/profiles/generic/generic-profile.ts');
const { filterAndSortRecords } = await vite.ssrLoadModule('/src/features/search-filter/filter-records.ts');
const { recordResultSheets } = await vite.ssrLoadModule('/src/features/linked-results/record-results.ts');
const { resultSectionsFor } = await vite.ssrLoadModule('/src/features/linked-results/result-message.ts');
const { toolIdForRecord } = await vite.ssrLoadModule('/src/importers/status-normalization.ts');
const options = { dataset, activeProfile: genericProfile, search: '', statusFilter: 'deviation', facetFilters: {},
sortColumn: 'all', sortDirection: 'asc', actionOnly: false, reviews: {} };
const counts = [];
for (const statusFilter of ['deviation', 'all']) {
const records = filterAndSortRecords(sheet, { ...options, statusFilter });
const report = exports.buildWorkViewExport({ dataset, sheet, records, generatedAt: new Date().toISOString() });
assert.deepEqual(report.entries.map(entry => entry.id), records.map(record => record.id));
let cells = 0;
let sources = 0;
let sections = 0;
for (const [index, record] of records.entries()) {
const entry = report.entries[index];
const tool = toolIdForRecord(record, sheet.toolId ?? dataset.profileId);
const linked = recordResultSheets(record, dataset.sheets, sheet.id, tool);
assert.deepEqual(entry.resultSheetIds, linked.map(item => item.id));
assert.equal(entry.resultRowCount, linked.reduce((sum, item) => sum + item.analysis.records.length, 0));
const actual = entry.observed.map(block => block.value).join('\n');
for (const item of linked) for (const row of item.analysis.records) for (const field of row.fields) {
assert.ok(actual.includes(`${field.label}: ${field.value}`), 'Lost result cell');
cells++;
}
for (const section of resultSectionsFor(record, tool)) {
assert.ok(entry.observed.some(block => block.value === section.value), 'Lost result section');
sections++;
}
for (const source of record.policySourceEvidence ?? []) {
assert.ok(actual.includes(source.id), 'Lost GPO observation');
assert.ok(actual.includes(source.sourceReference), 'Lost GPO evidence path');
sources++;
}
}
const csv = exports.renderWorkViewCsv(report);
const html = exports.renderWorkViewHtml(report);
const md = exports.renderWorkViewMarkdown(report);
const { read, utils } = await import('xlsx');
// Decode the UTF-8 BOM before passing a JS string to SheetJS (Excel handles it).
const book = read(csv.replace(/^\ufeff/, ''), { type: 'string', raw: true, FS: ';' });
const rows = utils.sheet_to_json(book.Sheets[book.SheetNames[0]], { header: 1, raw: true });
const expectedCsvRows = exports.workViewCsvRows(report);
assert.equal(rows.length, expectedCsvRows.length, 'CSV row count');
const differences = [];
rows.forEach((row, index) => row.forEach((cell, col) => {
const expected = expectedCsvRows[index][col];
if (cell !== expected) {
let first = 0;
while (first < Math.min(cell.length, expected.length) && cell[first] === expected[first]) first++;
differences.push({ row: index, column: col, actualLength: cell.length, expectedLength: expected.length,
firstDifference: first, actualChar: cell.charCodeAt(first), expectedChar: expected.charCodeAt(first) });
}
}));
if (differences.length) { console.log(JSON.stringify({ csvDifferences: differences.slice(0, 5) })); throw new Error('CSV content round-trip differs'); }
assert.ok(rows.every(row => row.length === 2), 'Not exactly two CSV columns');
assert.equal((html.match(/<article class="finding">/g) ?? []).length, records.length);
assert.equal((md.match(/^\| /gm) ?? []).length, records.length + 2);
counts.push({ statusFilter, records: records.length, csvContinuationRows: rows.length - records.length - 1, resultRows: report.entries.reduce((sum, item) => sum + item.resultRowCount, 0),
checkedResultCells: cells, resultSections: sections, gpoObservations: sources,
missingResultWarnings: report.entries.filter(item => item.observed.some(block => /nicht geladen/.test(block.value))).length,
oversizedCsvCells: rows.flat().filter(cell => String(cell).length > 32767).length,
bytes: { csv: Buffer.byteLength(csv), html: Buffer.byteLength(html), md: Buffer.byteLength(md) } });
}
console.log(JSON.stringify({ verification: 'passed', counts }, null, 2));
}

View File

@ -40,6 +40,10 @@ export interface ArtifactStoreReconciliationResult {
}
export interface BackendArtifactStore {
/** Cheap live accessibility check; integrity is still verified when reading. */
availability(storageKey: string): Promise<'available' | 'missing' | 'unavailable'>;
/** Restore only an absent object, with the original identity and checksum. */
restore(request: ReadArtifactRequest & { bytes: Uint8Array }): Promise<void>;
put(request: StoreArtifactRequest): Promise<StoredArtifactReceipt>;
read(request: ReadArtifactRequest): Promise<Uint8Array>;
remove(storageKey: string): Promise<boolean>;
@ -110,6 +114,41 @@ export class FileSystemBackendArtifactStore implements BackendArtifactStore {
this.objectRoot = join(this.root, 'objects');
}
async availability(storageKey: string): Promise<'available' | 'missing' | 'unavailable'> {
try {
const path = this.objectPath(storageKey);
for (const directory of [this.root, this.objectRoot]) {
const information = await lstat(directory);
if (!information.isDirectory() || information.isSymbolicLink()) return 'unavailable';
}
const information = await lstat(path);
if (!information.isFile() || information.isSymbolicLink()) return 'unavailable';
const handle = await open(path, 'r');
await handle.close();
return 'available';
} catch (error) {
return (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'missing' : 'unavailable';
}
}
async restore(request: ReadArtifactRequest & { bytes: Uint8Array }): Promise<void> {
const path = this.objectPath(request.storageKey);
// The complete store may have disappeared since a previous initialization.
this.initialization = undefined;
// put verifies the uploaded bytes before publication. link never overwrites
// an existing object, including one concurrently restored by another request.
const receipt = await this.put(request);
try {
try { await link(this.objectPath(receipt.storageKey), path); }
catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') throw error;
}
await this.read(request);
} finally {
await this.remove(receipt.storageKey);
}
}
async put(request: StoreArtifactRequest): Promise<StoredArtifactReceipt> {
validateMaximumBytes(request.maximumBytes);
if (request.bytes.byteLength > request.maximumBytes) {
@ -156,8 +195,11 @@ export class FileSystemBackendArtifactStore implements BackendArtifactStore {
let information;
try {
information = await lstat(path);
} catch {
throw storageError('artifact-store-object-missing', 'Das gespeicherte Artefakt wurde nicht gefunden.');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
throw storageError('artifact-store-object-missing', 'Stored source is missing. Upload the unchanged original file again.');
}
throw storageError('artifact-store-read-failed', 'The artifact store cannot be accessed. Check the backend storage connection and permissions.');
}
if (!information.isFile() || information.isSymbolicLink()) throw storageError('artifact-store-object-rejected', 'Das gespeicherte Objekt ist keine reguläre Datei.');
if (information.size > request.maximumBytes) {

View File

@ -1,3 +1,4 @@
import { RiskAcceptanceValidationError, type RiskAcceptanceCommand } from '../src/domain/risk-acceptance';
import { createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
@ -28,6 +29,7 @@ import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis
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 type { BackendArtifactStore } from './artifacts/backend-artifact-store';
import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility';
@ -116,6 +118,7 @@ export interface AuditEvent {
}
export interface AssessorHttpServerOptions {
artifactStore?: BackendArtifactStore;
repository: AssessorRepository;
staticRoot: string;
releaseId?: string;
@ -339,6 +342,7 @@ function statusForError(error: unknown): { status: number; code: string; message
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message };
if (error instanceof RepositoryValidationError) return { status: 400, code: 'repository_validation', message: error.message };
if (error instanceof RiskAcceptanceValidationError) return { status: 400, code: 'risk_acceptance_validation', message: error.message };
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
}
@ -779,7 +783,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 4 && segments[3] === 'status' && method === 'GET') {
const result = await listProjectGpoBackupStatus(repository, segments[1], now().toISOString());
const result = await listProjectGpoBackupStatus(repository, segments[1], now().toISOString(), options.artifactStore);
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 3 && method === 'POST') {
@ -791,12 +795,15 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (!isValidImportBase64(body.bytesBase64)) {
throw new HttpError(400, 'invalid_file_encoding', 'Der GPO-Container ist nicht gültig base64-kodiert.');
}
if (body.restoreArtifactId !== undefined && (typeof body.restoreArtifactId !== 'string' || !body.restoreArtifactId.trim())) {
throw new HttpError(400, 'invalid_gpo_restore', 'A valid GPO source identity is required.');
}
const bytes = Buffer.from(body.bytesBase64, 'base64');
if (bytes.byteLength !== body.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'Metadaten und tatsächliche GPO-Dateigröße stimmen nicht überein.');
const result: GpoBackupSubmissionResponse = await options.gpoBackupSubmissionService.submit({
projectId: segments[1], assessmentId: body.assessmentId, fileName: body.fileName, bytes, receivedAt: now().toISOString()
projectId: segments[1], assessmentId: body.assessmentId, restoreArtifactId: body.restoreArtifactId, fileName: body.fileName, bytes, receivedAt: now().toISOString()
});
const evaluation = result.deduplicated && options.automaticGpoAnalysis
const evaluation = result.deduplicated && !result.sourceRestored && !body.restoreArtifactId && 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));
@ -913,6 +920,12 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
await repository.saveReview(body.review, body.expectedRevision);
status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return;
}
if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && method === 'POST') {
const body = await readJson<{ occurrenceId: string; command: RiskAcceptanceCommand; expectedRevision: number }>(request, maximumBodyBytes);
if (typeof body.occurrenceId !== 'string' || !body.occurrenceId || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new HttpError(400, 'invalid_risk_acceptance', 'Finding and expected review revision are required.');
const review = await repository.recordRiskAcceptance(segments[1], body.occurrenceId, body.command, body.expectedRevision);
status = 200; sendJson(response, status, { review, repositoryRevision: repository.revision }, requestId); return;
}
if (segments.length === 1 && segments[0] === 'profiles' && method === 'GET') {
status = 200; sendJson(response, status, await repository.listProfileRegistrations(), requestId); return;
}

View File

@ -6,6 +6,7 @@ import {
createGpoReprocessingJob
} from './gpo-backup-submission-service';
import { processArtifactDeletionRequest } from './gpo-backup-management-service';
import { gpoSourceAvailability } from './gpo-source-availability';
export interface GpoArtifactReconciliationResult extends ArtifactStoreReconciliationResult {
repairedMissingJobs: number;
@ -13,6 +14,7 @@ export interface GpoArtifactReconciliationResult extends ArtifactStoreReconcilia
completedDeletionRequests: number;
pendingDeletionRequests: number;
invalidStorageReferences: number;
unavailableSources: number;
}
export interface GpoArtifactReconcilerOptions {
@ -60,6 +62,7 @@ export class GpoArtifactReconciler {
}
let repairedMissingJobs = 0;
let failedJobRepairs = 0;
let unavailableSources = 0;
for (const project of projects) {
const [artifacts, jobs, processingRuns, policyAnalyses] = await Promise.all([
this.repository.listImportArtifacts(project.id),
@ -85,6 +88,10 @@ export class GpoArtifactReconciler {
? createGpoReprocessingJob(artifact, latestRun.id, now.toISOString())
: createGpoBackupAnalysisJob(artifact, now.toISOString());
if (currentJobKeys.has(expectedJob.idempotencyKey)) continue;
if (await gpoSourceAvailability(artifact, this.artifactStore) !== 'available') {
unavailableSources += 1;
continue;
}
try {
const committed = await this.repository.saveImportArtifactAndEnqueueJob(artifact, expectedJob);
if (committed.jobCreated) repairedMissingJobs += 1;
@ -101,7 +108,7 @@ export class GpoArtifactReconciler {
removeStagingBefore: cutoff,
maximumRemovals: this.maximumRemovals
});
return { ...storage, repairedMissingJobs, failedJobRepairs, completedDeletionRequests, pendingDeletionRequests, invalidStorageReferences };
return { ...storage, repairedMissingJobs, failedJobRepairs, completedDeletionRequests, pendingDeletionRequests, invalidStorageReferences, unavailableSources };
}
}

View File

@ -5,6 +5,7 @@ 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';
import { currentBaselineAnalyses } from '../../src/domain/policy-analysis-overview';
export type AutomaticGpoAnalysisResult = {
state: 'context-required' | 'baseline-required' | 'review' | 'evaluated';
@ -72,9 +73,11 @@ export class GpoAutomaticAnalysisService {
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))
// Exactly one current analysis generation per baseline. An interrupted supersession
// chain would otherwise compare the same baseline twice and leave the saved run
// permanently reported as requiring re-evaluation.
const baselineAnalysisIds = currentBaselineAnalyses(analyses)
.filter(item => eligibleBaselineIds.has(item.analysis.baselineId))
.map(item => item.analysis.id).sort();
if (!baselineAnalysisIds.length) return { state: 'baseline-required' };
const aggregation = await this.snapshots.aggregate(projectId, {

View File

@ -9,7 +9,7 @@ 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.2.3';
export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.4';
export interface GpoBackupJobProcessorOptions {
leaseDurationMs?: number;
@ -170,7 +170,8 @@ export class GpoBackupJobProcessor {
const failedAt = this.now();
const code = errorCode(error);
const retryAt = new Date(failedAt.getTime() + this.retryDelayMs * Math.max(1, 2 ** (job.attempts - 1))).toISOString();
const failed = await this.repository.failIngestionJob(job.id, workerId, failedAt.toISOString(), code, retryAt);
const failed = await this.repository.failIngestionJob(job.id, workerId, failedAt.toISOString(), code, retryAt,
code !== 'artifact-store-object-missing');
return { jobId: job.id, status: failed.status, processingRunId, errorCode: code };
}
}

View File

@ -3,6 +3,7 @@ import type { ArtifactDeletionRequest, AssessorRepository } from '../../src/pers
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { createGpoReprocessingJob } from './gpo-backup-submission-service';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { requireGpoSource } from './gpo-source-availability';
function stableDeletionId(storageKey: string): string {
return `artifact-delete-${createHash('sha256').update(storageKey).digest('hex').slice(0, 32)}`;
@ -16,6 +17,9 @@ export class GpoBackupManagementService {
) {}
async retry(projectId: string, artifactId: string) {
const artifact = (await this.repository.listImportArtifacts(projectId)).find(item => item.id === artifactId);
if (!artifact) throw new RepositoryNotFoundError('The GPO artifact is not present in this project.');
await requireGpoSource(artifact, this.artifactStore);
const jobs = (await this.repository.listIngestionJobs(projectId))
.filter(item => item.artifactId === artifactId && item.kind === 'analyze-gpo-backup')
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id));
@ -36,6 +40,7 @@ export class GpoBackupManagementService {
if (latest.id !== expectedLatestProcessingRunId) {
throw new RepositoryConflictError('Der erwartete letzte Processing Run ist nicht mehr aktuell.');
}
await requireGpoSource(artifact, this.artifactStore);
const job = await this.repository.enqueueIngestionJob(createGpoReprocessingJob(artifact, latest.id, this.now().toISOString()));
return {
version: 1 as const,

View File

@ -1,7 +1,34 @@
import type { GpoBackupStatusRecord, GpoBackupStatusResponse } from '../../src/api/assessor-api-contract';
import type { GpoAnalysisUpdate, GpoBackupStatusRecord, GpoBackupStatusResponse } from '../../src/api/assessor-api-contract';
import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { gpoSourceAvailability } from './gpo-source-availability';
import type { ImportProcessingRun, IngestionJob } from '../../src/domain/ingestion-persistence';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError } from '../../src/persistence/repository-errors';
import { gpoEvaluationUpdates, type GpoEvaluationUpdate } from '../../src/domain/policy-analysis-overview';
// Bounded per repository; every persisted change invalidates the derived readiness.
const evaluationCache = new WeakMap<AssessorRepository, Map<string, { revision: number; value: Promise<GpoEvaluationUpdate[]> }>>();
function evaluationUpdates(repository: AssessorRepository, projectId: string): Promise<GpoEvaluationUpdate[]> {
let cache = evaluationCache.get(repository);
if (!cache) evaluationCache.set(repository, cache = new Map());
const cached = cache.get(projectId);
if (cached?.revision === repository.revision) return cached.value;
const revision = repository.revision;
const value = Promise.all([
repository.listGpoPolicyAnalyses(projectId), repository.listEnvironmentProfiles(projectId),
repository.listPolicySnapshotAggregations(projectId), repository.listPolicyEvaluations(projectId),
// A retained evaluation stays pinned to its own baseline analysis ids. Readiness
// therefore needs the current analysis generation, not only the engine version.
repository.listMicrosoftSecurityBaselineAnalyses()
]).then(([identityAnalyses, profiles, aggregations, evaluations, baselineAnalyses]) =>
gpoEvaluationUpdates({ identityAnalyses, profiles, aggregations, evaluations, baselineAnalyses }));
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
const entry = { revision, value };
cache.set(projectId, entry);
void value.catch(() => { if (cache.get(projectId) === entry) cache.delete(projectId); });
return value;
}
function latestByUpdatedAt(jobs: IngestionJob[]): IngestionJob | undefined {
return jobs.slice().sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id))[0];
@ -21,7 +48,8 @@ function displayState(job: IngestionJob | undefined, run: ImportProcessingRun |
export async function listProjectGpoBackupStatus(
repository: AssessorRepository,
projectId: string,
generatedAt: string
generatedAt: string,
artifactStore?: BackendArtifactStore
): Promise<GpoBackupStatusResponse> {
if (!await repository.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const [artifacts, jobs, instances, runs] = await Promise.all([
@ -31,20 +59,43 @@ export async function listProjectGpoBackupStatus(
repository.listProjectProcessingRuns(projectId)
]);
const records: GpoBackupStatusRecord[] = [];
const hasGpoBackups = artifacts.some(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'));
const updates = hasGpoBackups ? await evaluationUpdates(repository, projectId) : [];
// A newer analyzer changes what parsing retains, so the stored source needs reprocessing.
// Superseded analyses are historical and never request work of their own.
const analyses = hasGpoBackups ? await repository.listGpoPolicyAnalyses(projectId) : [];
const supersededAnalysisIds = new Set(analyses.flatMap(item => item.run.supersedesAnalysisRunId
? [item.run.supersedesAnalysisRunId] : []));
const outdatedAnalyses = new Map<string, GpoAnalysisUpdate>();
for (const item of analyses) {
if (supersededAnalysisIds.has(item.run.id) || item.run.analyzer.version === GPO_BACKUP_ANALYZER_VERSION) continue;
outdatedAnalyses.set(item.run.artifactId, {
identityAnalysisRunId: item.run.id,
expectedLatestProcessingRunId: item.run.sourceProcessingRunId,
previousAnalyzerVersion: item.run.analyzer.version,
currentAnalyzerVersion: GPO_BACKUP_ANALYZER_VERSION
});
}
for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) {
const job = latestByUpdatedAt(jobs.filter(item => item.kind === 'analyze-gpo-backup' && item.artifactId === artifact.id));
const run = latestRun(runs.filter(item => item.artifactId === artifact.id));
const gpoCount = run ? instances.filter(item => item.processingRunId === run.id).length : 0;
// Availability changes independently of the repository revision; do not cache it.
const sourceAvailability = await gpoSourceAvailability(artifact, artifactStore);
records.push({
version: 1,
artifactId: artifact.id,
evaluationUpdates: updates.filter(update => update.artifactIds.includes(artifact.id)),
analysisUpdate: outdatedAnalyses.get(artifact.id),
assessmentId: artifact.assessmentId,
jobId: job?.id,
fileName: artifact.relativePath.slice('gpo-backups/'.length),
sizeBytes: artifact.sizeBytes,
submittedAt: artifact.createdAt,
updatedAt: job?.updatedAt ?? artifact.createdAt,
state: displayState(job, run),
state: sourceAvailability === 'missing' ? 'upload-required'
: sourceAvailability === 'unavailable' ? 'storage-unavailable' : displayState(job, run),
jobStatus: job?.status,
attempts: job?.attempts ?? 0,
maxAttempts: job?.maxAttempts ?? 3,
lastErrorCode: job?.lastErrorCode,

View File

@ -5,10 +5,12 @@ import type { AssessorRepository } from '../../src/persistence/assessor-reposito
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import type { GpoInventoryExecutor } from './gpo-inventory-executor';
import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.3';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.4';
export interface SubmitGpoBackupRequest {
restoreArtifactId?: string;
projectId: string;
assessmentId?: string;
fileName: string;
@ -17,6 +19,7 @@ export interface SubmitGpoBackupRequest {
}
export interface SubmitGpoBackupResult {
sourceRestored?: boolean;
version: 1;
artifactId: string;
jobId: string;
@ -100,22 +103,39 @@ export class GpoBackupSubmissionService {
if (request.bytes.byteLength < 1 || request.bytes.byteLength > this.maximumBytes) {
throw new RepositoryValidationError('Der GPO-Container überschreitet das zulässige Größenlimit.');
}
const relativePath = validatedRelativePath(request.fileName);
let relativePath = validatedRelativePath(request.fileName);
if (!await this.repository.getProject(request.projectId)) throw new RepositoryNotFoundError(`Projekt ${request.projectId} ist nicht vorhanden.`);
if (request.assessmentId) {
const assessment = (await this.repository.listAssessments(request.projectId)).find(item => item.id === request.assessmentId);
if (!assessment) throw new RepositoryNotFoundError('Der gewählte Timeslot ist für diesen Mandanten nicht vorhanden.');
}
const sha256 = createHash('sha256').update(request.bytes).digest('hex');
const artifactId = stableId('gpo-artifact', `${request.projectId}\0${request.assessmentId ?? 'unassigned'}\0${relativePath}\0${sha256}`);
const existing = (await this.repository.listImportArtifacts(request.projectId)).find(item => item.id === artifactId);
const artifacts = await this.repository.listImportArtifacts(request.projectId);
const target = request.restoreArtifactId ? artifacts.find(item => item.id === request.restoreArtifactId
&& item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/')) : undefined;
if (request.restoreArtifactId && !target) throw new RepositoryNotFoundError('The GPO source to restore is not present in this project.');
if (target && (target.sha256 !== sha256 || target.sizeBytes !== request.bytes.byteLength
|| (request.assessmentId !== undefined && target.assessmentId !== request.assessmentId))) {
throw new RepositoryValidationError('This upload does not match the missing source. Select the unchanged original GPO ZIP or backup folder.');
}
if (target) relativePath = target.relativePath;
const artifactId = target?.id ?? stableId('gpo-artifact', `${request.projectId}\0${request.assessmentId ?? 'unassigned'}\0${relativePath}\0${sha256}`);
const existing = target ?? artifacts.find(item => item.id === artifactId);
let candidate: ImportArtifact;
let newStorageKey: string | undefined;
let sourceRestored = false;
if (existing) {
if (existing.sha256 !== sha256 || existing.sizeBytes !== request.bytes.byteLength || existing.storage.kind !== 'backend-controlled') {
throw new RepositoryValidationError('Die vorhandene GPO-Artefaktidentität ist inkonsistent.');
}
candidate = existing;
if (await gpoSourceAvailability(existing, this.artifactStore) === 'missing') {
await this.artifactStore.restore({ storageKey: existing.storage.storageKey, expectedSha256: sha256,
maximumBytes: this.maximumBytes, bytes: request.bytes });
sourceRestored = true;
} else {
await requireGpoSource(existing, this.artifactStore);
}
} else {
const recognition = await this.inventoryExecutor.recognize({ bytes: request.bytes });
if (recognition.recognition.status !== 'gpo-backup-candidate') throw new RepositoryValidationError('Der Container ist kein unterstützter GPO-Backupkandidat.');
@ -149,9 +169,12 @@ export class GpoBackupSubmissionService {
.filter(item => item.artifactId === candidate.id && item.kind === 'analyze-gpo-backup')
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id))[0];
if (!latestJob) throw new RepositoryNotFoundError('Der aktuelle GPO-Analysejob ist nicht vorhanden.');
if (latestJob.status === 'dead-letter' && latestJob.lastErrorCode === 'artifact-store-object-missing') {
await this.repository.retryIngestionJob(request.projectId, latestJob.id, request.receivedAt);
}
return {
version: 1, artifactId: candidate.id, jobId: latestJob.id, sha256, sizeBytes: candidate.sizeBytes,
status: 'queued', deduplicated: true, repositoryRevision: this.repository.revision
status: 'queued', deduplicated: true, sourceRestored, repositoryRevision: this.repository.revision
};
}
}
@ -159,6 +182,9 @@ export class GpoBackupSubmissionService {
? createGpoReprocessingJob(candidate, latestRun.id, request.receivedAt)
: createGpoBackupAnalysisJob(candidate, request.receivedAt);
const committed = await this.repository.saveImportArtifactAndEnqueueJob(candidate, analysisJob);
if (committed.job.status === 'dead-letter' && committed.job.lastErrorCode === 'artifact-store-object-missing') {
await this.repository.retryIngestionJob(request.projectId, committed.job.id, request.receivedAt);
}
if (newStorageKey && !committed.artifactCreated) await this.artifactStore.remove(newStorageKey);
return {
version: 1,
@ -168,6 +194,7 @@ export class GpoBackupSubmissionService {
sizeBytes: committed.artifact.sizeBytes,
status: 'queued',
deduplicated: Boolean(existing) || !committed.artifactCreated || !committed.jobCreated,
sourceRestored,
repositoryRevision: this.repository.revision
};
} catch (error) {

View File

@ -1,139 +0,0 @@
import { canonicalJson } from '../../src/shared/canonical-json';
import type { AggregatedPolicyState, EnvironmentProfile, StoredPolicySnapshotAggregation } from '../../src/domain/gpo-snapshot-aggregation';
import type { PolicyBaselineState, PolicyComparatorDefinition } from '../../src/domain/policy-evaluation';
import {
policyEvaluationRulesForContext,
type PolicyDependencyRule,
type PolicyEvaluationRule,
type PolicyEvaluationRuleCatalog
} from '../../src/domain/policy-evaluation-rules';
export interface PolicyRuleResolution {
baselineState: PolicyBaselineState;
comparator: PolicyComparatorDefinition;
source: 'baseline-setting' | 'rule-catalog';
ruleIds: string[];
dependencyRuleIds: string[];
riskRuleIds: string[];
notApplicable: boolean;
diagnosticCodes: string[];
}
function sortedUnique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
function dependencyTarget(
aggregation: StoredPolicySnapshotAggregation,
dependency: PolicyDependencyRule
): AggregatedPolicyState | undefined {
return aggregation.states.find(state => state.identity.id === dependency.identityId
&& state.deliveryMechanism === dependency.deliveryMechanism);
}
function targetIsConfigured(target: AggregatedPolicyState): boolean {
return target.status === 'configured' && target.completeness === 'complete' && target.targeting === 'none';
}
function dependencyResolution(
aggregation: StoredPolicySnapshotAggregation,
dependencies: PolicyDependencyRule[]
): { notApplicable: boolean; diagnosticCodes: string[] } {
const diagnostics = new Set<string>();
let notApplicable = false;
for (const dependency of dependencies) {
const target = dependencyTarget(aggregation, dependency);
if (!target) {
if (!aggregation.snapshot.permitsSetAbsenceConclusions) {
diagnostics.add('policy-dependency-target-absence-unknown');
} else if (dependency.kind === 'prerequisite') {
notApplicable = true;
}
continue;
}
if (target.status === 'conflicted' || target.status === 'indeterminate'
|| target.completeness !== 'complete' || target.targeting !== 'none') {
diagnostics.add('policy-dependency-target-indeterminate');
continue;
}
const configured = targetIsConfigured(target);
if (dependency.kind === 'prerequisite' && !configured) notApplicable = true;
if (dependency.kind === 'override' && configured) notApplicable = true;
if (dependency.kind === 'mutual-exclusion' && configured) {
diagnostics.add('policy-dependency-mutual-exclusion-active');
}
}
return { notApplicable, diagnosticCodes: [...diagnostics].sort() };
}
export function resolvePolicyEvaluationRule(
catalog: PolicyEvaluationRuleCatalog,
profile: EnvironmentProfile,
aggregation: StoredPolicySnapshotAggregation,
state: AggregatedPolicyState
): 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) {
diagnostics.add('policy-evaluation-context-missing');
continue;
}
const candidates = policyEvaluationRulesForContext(
catalog, state.identity.id, state.deliveryMechanism, context, state.identity.scope
);
if (candidates.length !== 1) {
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
: 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');
const riskSignatures = new Set(recommendationRules.map(rule => canonicalJson(rule.riskRule)));
if (riskSignatures.size > 1) diagnostics.add('policy-evaluation-risk-consensus-conflict');
if (baselineState === 'no-recommendation') diagnostics.add('policy-evaluation-no-recommendation');
const dependencies = [...new Map(rules.flatMap(rule => rule.dependencies).map(rule => [rule.id, rule])).values()]
.sort((left, right) => left.id.localeCompare(right.id));
const conflictingDependencyIds = new Set<string>();
for (const rule of rules) {
for (const dependency of rule.dependencies) {
const sameId = dependencies.find(candidate => candidate.id === dependency.id);
if (sameId && canonicalJson(sameId) !== canonicalJson(dependency)) conflictingDependencyIds.add(dependency.id);
}
}
if (conflictingDependencyIds.size) diagnostics.add('policy-evaluation-dependency-definition-conflict');
const dependency = dependencyResolution(aggregation, dependencies);
dependency.diagnosticCodes.forEach(code => diagnostics.add(code));
let notApplicable = baselineState === 'explicitly-not-applicable' || dependency.notApplicable;
if (dependency.notApplicable) baselineState = 'explicitly-not-applicable';
if (diagnostics.size) notApplicable = false;
const comparator = recommendationRules[0]?.comparator ?? { kind: 'equality' as const };
return {
baselineState,
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] : [])),
notApplicable,
diagnosticCodes: [...diagnostics].sort()
};
}

View File

@ -5,6 +5,7 @@ import { canonicalJson } from '../../src/shared/canonical-json';
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 type { GpoTechnicalPolicyIdentity } from '../../src/domain/gpo-policy-identity';
import {
POLICY_COMPARATOR_CATALOG,
POLICY_DEPENDENCY_RULESET,
@ -17,7 +18,7 @@ import {
type PolicyEvaluationResult,
type StoredPolicyEvaluation
} from '../../src/domain/policy-evaluation';
import { comparePolicyValues } from '../../src/evaluation/policy-comparator';
import { compareBaselineVariants } from '../../src/evaluation/policy-baseline-variants';
import { selectBaselineTemplates } from '../../src/evaluation/baseline-template-context';
import {
RELEASED_POLICY_EVALUATION_RULE_CATALOG,
@ -33,6 +34,31 @@ function sortedUnique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
const VALUE_INDEPENDENT_BASELINE_DIAGNOSTICS = new Set([
'microsoft-baseline-admx-identity-ambiguous', 'microsoft-baseline-admx-metadata-not-found',
'adml-without-admx', 'gpo-metadata-domain-missing', 'gpo-metadata-backup-id-missing',
'gpo-metadata-backup-time-missing', 'gpo-metadata-backup-time-zone-missing'
]);
function baselinePartialityBlocksCandidates(
analysis: StoredMicrosoftSecurityBaselineAnalysis,
candidates: MicrosoftSecurityBaselineSetting[]
): boolean {
if (analysis.analysis.status === 'complete') return false;
if (!analysis.analysis.diagnosticCodes.length) return true;
const mechanisms = new Set(candidates.map(setting => setting.observation.deliveryMechanism));
return analysis.analysis.diagnosticCodes.some(code => {
if (code.startsWith('admx-') || VALUE_INDEPENDENT_BASELINE_DIAGNOSTICS.has(code)) return false;
// Unsupported sections have no comparable identity. Their coverage gap remains
// visible at run level but cannot invalidate a separately resolved native value.
if (code === 'gpo-security-template-section-unsupported') return false;
if (code.startsWith('gpo-security-template-')) return mechanisms.has('security-template');
if (code.startsWith('gpo-advanced-audit-')) return mechanisms.has('advanced-audit-policy');
if (code.startsWith('registry-policy-')) return mechanisms.has('registry-policy');
return true;
});
}
interface EvaluationBuildInput {
profile: EnvironmentProfile;
aggregation: StoredPolicySnapshotAggregation;
@ -64,7 +90,10 @@ function evaluateBaselineIdentity(
baseline: MicrosoftSecurityBaselineRecord,
candidates: MicrosoftSecurityBaselineSetting[],
state: AggregatedPolicyState | undefined,
profile: EnvironmentProfile
relatedRegistryValueStates: AggregatedPolicyState[],
profile: EnvironmentProfile,
permitsAbsence: boolean,
aggregationBlocked: boolean
): { result: PolicyEvaluationResult; evidence: PolicyEvaluationEvidence[] } {
const diagnostics = new Set<string>();
const selection = selectBaselineTemplates(candidates, profile);
@ -73,42 +102,71 @@ function evaluateBaselineIdentity(
if (selection.status === 'no-match') diagnostics.add('policy-evaluation-template-context-no-match');
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') {
const baselineBlocked = baselinePartialityBlocksCandidates(analysis, settings);
if (baselineBlocked || analysis.analysis.admxCatalog.status !== 'complete') {
diagnostics.add('policy-evaluation-baseline-incomplete');
}
const recommendationSignatures = new Set(settings.map(setting => canonicalJson(setting.observation.values[0])));
if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-consensus-conflict');
if (recommendationSignatures.size > 1 && selection.status === 'unresolved') {
diagnostics.add('policy-evaluation-template-context-required');
}
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'
const recommendationSignatures = new Set(settings.map(setting => canonicalJson(setting.observation.values)));
if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-variants-differ');
const configuredChildStates = !state && identity.target.kind === 'registry-key'
? relatedRegistryValueStates.filter(item => item.status === 'configured' || item.status === 'conflicted') : [];
const evidenceStates = state ? [state] : configuredChildStates;
const actualRegistryState = configuredChildStates.length
? { kind: 'registry-state' as const, state: 'key-present' as const } : undefined;
const actualValues = evidenceStates.flatMap(item => item.values);
const customerSignatures = new Set(actualValues.map(value => canonicalJson(value)));
if (evidenceStates.length && customerSignatures.size !== 1) diagnostics.add('policy-evaluation-customer-value-ambiguous');
// An explicit Registry.pol delete instruction is configured evidence. Only
// the complete absence of a state is a missing customer setting.
const customerState: PolicyEvaluationResult['customerState'] = !state && !configuredChildStates.length
? 'missing'
: state.status === 'conflicted' ? 'conflicted'
: state.status === 'indeterminate' ? 'indeterminate' : 'configured';
: state?.status === 'conflicted' || configuredChildStates.some(item => item.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 = {
const blockers: string[] = [];
// Several instructions inside one baseline GPO are not independent role alternatives.
// Without a uniquely reduced recommendation, their precedence remains unresolved.
const firstByBackup = new Map<string, MicrosoftSecurityBaselineSetting>();
for (const setting of settings) {
const first = firstByBackup.get(setting.backupId);
if (first && compareBaselineVariants(first.observation.values, [setting], false, []).decision !== 'all-pass') {
blockers.push('policy-evaluation-template-values-ambiguous');
break;
}
firstByBackup.set(setting.backupId, setting);
}
if (selection.status === 'no-match') blockers.push('policy-evaluation-template-context-no-match');
// Missing explanations or baseline domain/time metadata do not make resolved native values unreadable.
// Unknown/structural partiality still blocks conclusions across the candidate set.
if (baselineBlocked) blockers.push('policy-evaluation-baseline-incomplete');
if (aggregationBlocked) blockers.push('policy-evaluation-aggregation-blocked');
if (customerState === 'missing' && !permitsAbsence) {
// The verdict is scoped to the analyzed backup set. It deliberately does
// not claim domain-wide or effective-device absence without RSoP.
diagnostics.add('policy-evaluation-customer-absence-scope-limited');
}
if (customerState === 'indeterminate' || evidenceStates.some(item => item.completeness !== 'complete')) {
blockers.push('policy-evaluation-customer-state-incomplete');
}
const assessment = compareBaselineVariants(customerState === 'missing' ? [] : actualValues, settings,
customerState === 'missing', blockers, actualRegistryState);
assessment.variants.flatMap(variant => variant.comparator.diagnosticCodes).forEach(code => diagnostics.add(code));
blockers.forEach(code => diagnostics.add(code));
if (assessment.decision === 'mixed' && selection.status === 'unresolved') diagnostics.add('policy-evaluation-template-context-required');
const review = assessment.decision === 'mixed' || assessment.decision === 'unavailable';
const actualComparisons = assessment.variants.map(variant => variant.comparator.actual);
const actualComparisonSignatures = new Set(actualComparisons.map(value => canonicalJson(value)));
// Retain a scalar baseline only when it is genuinely shared. Never synthesize a union.
const compared: PolicyComparatorOutcome = {
version: 1,
comparator: { kind: 'equality' },
relation: customerState === 'missing' ? 'different' : 'incomparable',
baseline: undefined,
relation: review ? 'incomparable' : assessment.decision === 'all-pass' ? 'match' : 'different',
actual: assessment.actualValues.length === 1 || (actualComparisons.length && actualComparisonSignatures.size === 1)
? actualComparisons[0] : undefined,
baseline: recommendationSignatures.size === 1 ? assessment.variants[0]?.comparator.baseline : undefined,
diagnosticCodes: []
};
if (selection.status !== 'no-match' && 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 (selection.status !== 'no-match' && 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 = selection.status === 'no-match' || recommendationSignatures.size !== 1 || ['conflicted', 'indeterminate'].includes(customerState)
|| compared.relation === 'incomparable';
const classification: PolicyEvaluationResult['classification'] = review
? 'review_required'
: compared.relation === 'match' ? 'configured_match' : 'confirmed_configured_deviation';
@ -120,20 +178,25 @@ function evaluateBaselineIdentity(
id: resultId,
evaluationRunId: runId,
aggregationStateId: customerState === 'missing' ? undefined : state?.id,
aggregationStateIds: customerState === 'missing' || state ? undefined : sortedUnique(evidenceStates.map(item => item.id)),
baselineAnalysisId: analysis.analysis.id,
baselineId: baseline.id,
baselineLabel: baseline.fileName.replace(/\.zip$/i, ''),
customerState,
identity,
classification,
baselineState: compared.diagnosticCodes.length ? 'unsupported-setting' : 'explicit-recommendation',
baselineState: ['all-pass', 'all-fail'].includes(assessment.decision)
? 'explicit-recommendation'
: assessment.variants.some(variant => variant.comparator.diagnosticCodes.length)
? 'unsupported-setting' : 'explicit-recommendation',
comparator: compared,
baselineVariantAssessment: assessment,
ruleIds: [],
dependencyRuleIds: [],
riskRuleIds: [],
contextIds: sortedUnique([...selection.contextIds, ...(state?.applicableContextIds ?? [])]),
observationIds: customerState === 'missing' ? [] : sortedUnique(state?.observationIds ?? []),
backupInstanceIds: customerState === 'missing' ? [] : sortedUnique(state?.backupInstanceIds ?? []),
observationIds: customerState === 'missing' ? [] : sortedUnique(evidenceStates.flatMap(item => item.observationIds)),
backupInstanceIds: customerState === 'missing' ? [] : sortedUnique(evidenceStates.flatMap(item => item.backupInstanceIds)),
baselineAnalysisIds,
baselineSettingIds,
baselineTemplateSelection: { status: selection.status, contextIds: selection.contextIds,
@ -169,6 +232,11 @@ export function buildPolicyEvaluation(input: EvaluationBuildInput): StoredPolicy
...baselineAnalysisIds
].join('\0'));
const statesByIdentity = new Map(input.aggregation.states.map(state => [state.identity.id, state]));
const relatedRegistryValueStates = (identity: GpoTechnicalPolicyIdentity) => identity.target.kind !== 'registry-key' ? []
: input.aggregation.states.filter(state => state.identity.scope === identity.scope
&& state.identity.target.kind === 'registry-value'
&& state.identity.target.hive === identity.target.hive
&& state.identity.target.key.toLocaleLowerCase('en-US') === identity.target.key.toLocaleLowerCase('en-US'));
const baselinesById = new Map(input.baselineRecords.map(record => [record.id, record]));
const built = input.baselineAnalyses.flatMap(analysis => {
const baseline = baselinesById.get(analysis.analysis.baselineId);
@ -176,14 +244,20 @@ export function buildPolicyEvaluation(input: EvaluationBuildInput): StoredPolicy
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), input.profile));
// Wholly unconfigured identities are not benchmarks. An unreadable variant
// beside a recommendation must remain visible and block false unanimity.
.filter(([, settings]) => settings.some(setting => setting.observation.values.length > 0))
.map(([identityId, settings]) => {
const identity = settings[0].identity.status === 'resolved' ? settings[0].identity.identity : undefined;
if (!identity) throw new RepositoryValidationError('Eine Baselinegruppe besitzt keine aufgeloeste technische Identitaet.');
return evaluateBaselineIdentity(runId, analysis, baseline, settings, statesByIdentity.get(identityId),
relatedRegistryValueStates(identity), input.profile, input.aggregation.snapshot.permitsSetAbsenceConclusions,
input.aggregation.run.status === 'blocked');
});
});
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));

View File

@ -0,0 +1,18 @@
import type { ImportArtifact } from '../../src/domain/ingestion-persistence';
import { ImportError } from '../../src/shared/errors';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
export async function gpoSourceAvailability(artifact: ImportArtifact, store?: BackendArtifactStore) {
if (artifact.storage.kind !== 'backend-controlled') return 'missing' as const;
return store ? store.availability(artifact.storage.storageKey) : 'unavailable' as const;
}
export async function requireGpoSource(artifact: ImportArtifact, store: BackendArtifactStore): Promise<void> {
const availability = await gpoSourceAvailability(artifact, store);
if (availability === 'available') return;
const code = availability === 'missing' ? 'artifact-store-object-missing' : 'artifact-store-read-failed';
const message = availability === 'missing'
? 'Stored GPO source is missing. Upload the unchanged original backup again. Saved results remain available.'
: 'The artifact store cannot be accessed. Check the backend storage connection and permissions.';
throw new ImportError(message, [{ severity: 'error', code, message }]);
}

View File

@ -10,9 +10,12 @@ export function resolvePolicyTarget(observation: GpoPolicyObservation, catalog?:
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 section = observation.identity.section.normalize('NFC');
const key = observation.identity.key.normalize('NFC');
// The digest stays case-folded so one technical setting keeps one identity across
// differently written sources. The retained target keeps the written form, exactly as
// the registry resolver already does, so the value stays recognisable and searchable.
const digest = createHash('sha256').update(['native-policy-target-v1', kind, observation.scope, section.toLowerCase(), key.toLowerCase()].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 }

View File

@ -7,7 +7,7 @@ import { resolveImportResourcePolicy, validateDecodedTextResources } from '../..
import { ImportError } from '../../src/shared/errors';
import type { VerifiedZipFile } from './zip-inventory';
export const SECURITY_TEMPLATE_PARSER = { id: 'gpo-security-template-inf', version: '1.1.0' } as const;
export const SECURITY_TEMPLATE_PARSER = { id: 'gpo-security-template-inf', version: '1.2.0' } as const;
export const SECURITY_TEMPLATE_REDUCER = { id: 'gpo-security-template-reducer', version: '1.0.0' } as const;
const supportedSections = new Set(['system access', 'kerberos policy', 'privilege rights', 'registry values']);
@ -18,7 +18,10 @@ const maximumFieldCharacters = 4_095;
export interface SecurityTemplateInstruction {
version: 1;
/** Case-folded section for grammar selection, grouping and identity digests. */
section: string;
/** The section exactly as written in the source, e.g. `Privilege Rights`. */
sourceSection: string;
key: string;
rawValue: string;
line: number;
@ -71,6 +74,7 @@ export function parseSecurityTemplateInf(
const diagnostics = new Set<string>();
const instructions: SecurityTemplateInstruction[] = [];
let section: string | undefined;
let sourceSection: string | undefined;
let invalid = false;
for (let index = 0; index < lines.length; index += 1) {
const rawLine = lines[index];
@ -84,7 +88,10 @@ export function parseSecurityTemplateInf(
if (!trimmed || trimmed.startsWith(';')) continue;
const sectionMatch = /^\[([^\[\]]+)\]$/.exec(trimmed);
if (sectionMatch) {
section = sectionMatch[1].trim().toLocaleLowerCase('en-US');
// Section names select the entry grammar case-insensitively, but the written form is
// the source value and must survive normalization.
sourceSection = sectionMatch[1].trim();
section = sourceSection.toLocaleLowerCase('en-US');
if (!section || section.length > 256) { diagnostics.add('gpo-security-template-section-invalid'); invalid = true; }
else if (!supportedSections.has(section) && !metadataSections.has(section)) diagnostics.add('gpo-security-template-section-unsupported');
continue;
@ -94,6 +101,7 @@ export function parseSecurityTemplateInf(
diagnostics.add('gpo-security-template-section-invalid');
invalid = true;
section = undefined;
sourceSection = undefined;
continue;
}
if (!section) { diagnostics.add('gpo-security-template-entry-outside-section'); invalid = true; continue; }
@ -111,7 +119,7 @@ export function parseSecurityTemplateInf(
diagnostics.add('gpo-security-template-registry-value-invalid');
continue;
}
instructions.push({ version: 1, section, key, rawValue, line });
instructions.push({ version: 1, section, sourceSection: sourceSection ?? section, key, rawValue, line });
}
return {
version: 1,
@ -184,7 +192,9 @@ export function reduceSecurityTemplate(
identity: {
status: 'unresolved',
namespace: 'security-template',
section: first.section,
// Retain the written section and key; the case-folded forms stay internal to
// grouping and the identity digest, which keeps existing observation IDs stable.
section: first.sourceSection,
key: first.key
},
state: conflicted ? 'conflicted' : 'configured',

View File

@ -100,6 +100,7 @@ async function main(): Promise<void> {
error => console.error('GPO-Artefakt-Reconciliation fehlgeschlagen.', error instanceof Error ? error.message : 'Unbekannter Fehler')
);
const server = createAssessorHttpServer({
artifactStore,
repository: runtime.repository,
persistence: runtime.persistence,
staticRoot,

View File

@ -1,4 +1,4 @@
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import type {
DecodedHtmlDocument,
DecodedXmlDocument,
@ -14,6 +14,13 @@ import {
shouldUseBoundedHtmlExtraction
} from '../src/importers/html-extraction';
// A worker gets a fresh module graph for every source. Load the fixed local
// DOM dependency only when markup actually needs it, not for CSV/JSON/Excel.
const requireMarkup = createRequire(import.meta.url);
function markupConstructor(): typeof import('jsdom').JSDOM {
return (requireMarkup('jsdom') as typeof import('jsdom')).JSDOM;
}
function walkXml(node: Element, path: string, rows: DecodedXmlRow[]): void {
const attributes = [...node.attributes].map(attribute => `${attribute.name}="${attribute.value}"`).join('; ');
const attributeValues = Object.fromEntries([...node.attributes].map(attribute => [attribute.name, attribute.value]));
@ -30,6 +37,7 @@ export const nodeMarkupDocumentAdapter: MarkupDocumentAdapter = {
decodeHtml(text: string): DecodedHtmlDocument {
if (shouldUseBoundedHtmlExtraction(text)) return decodeHtmlLexically(text);
try {
const JSDOM = markupConstructor();
const documentNode = new JSDOM(text, { contentType: 'text/html' }).window.document;
return decodeHtmlDocument(documentNode);
} catch (error) {
@ -43,6 +51,7 @@ export const nodeMarkupDocumentAdapter: MarkupDocumentAdapter = {
return { valid: false, message: 'XML-Dokumenttyp-, Entitäts- und externe Referenzdeklarationen sind nicht zulässig. xml-doctype-rejected-by-adapter' };
}
try {
const JSDOM = markupConstructor();
const documentNode = new JSDOM(text, { contentType: 'application/xml' }).window.document;
const root = documentNode.documentElement;
const rows: DecodedXmlRow[] = [];

View File

@ -1,3 +1,4 @@
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../../src/domain/risk-acceptance';
import type { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
import type {
Assessment,
@ -613,8 +614,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
`, [definition.id, definition.canonicalKey, json(definition)]);
if (!result.rowCount) throw new RepositoryConflictError(`Finding-Definition ${definition.id} besitzt einen widersprüchlichen kanonischen Schlüssel.`);
}
const stagedImportFileById = new Map(staged.importFiles.map(file => [file.id, file]));
const stagedDefinitionById = new Map(staged.findingDefinitions.map(definition => [definition.id, definition]));
for (const occurrence of staged.findingOccurrences) {
const occurrenceImportFile = staged.importFiles.find(file => file.id === occurrence.importFileId);
const occurrenceImportFile = stagedImportFileById.get(occurrence.importFileId);
await client.query(`
INSERT INTO assessor_finding_occurrences (
id, definition_id, scan_run_id, import_file_id, source_table_id, tool_id,
@ -637,7 +640,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
await persistOccurrenceCategories(
client,
occurrence,
staged.findingDefinitions.find(definition => definition.id === occurrence.definitionId),
stagedDefinitionById.get(occurrence.definitionId),
occurrenceImportFile
);
}
@ -774,8 +777,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
`, [definition.id, definition.canonicalKey, json(definition)]);
if (!result.rowCount) throw new RepositoryConflictError(`Finding-Definition ${definition.id} besitzt einen widersprüchlichen kanonischen Schlüssel.`);
}
const importFileById = new Map(model.importFiles.map(file => [file.id, file]));
const definitionById = new Map(model.findingDefinitions.map(definition => [definition.id, definition]));
for (const occurrence of model.findingOccurrences) {
const occurrenceImportFile = model.importFiles.find(file => file.id === occurrence.importFileId);
const occurrenceImportFile = importFileById.get(occurrence.importFileId);
await client.query(`
INSERT INTO assessor_finding_occurrences (
id, definition_id, scan_run_id, import_file_id, source_table_id, tool_id,
@ -798,7 +803,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
await persistOccurrenceCategories(
client,
occurrence,
model.findingDefinitions.find(definition => definition.id === occurrence.definitionId),
definitionById.get(occurrence.definitionId),
occurrenceImportFile
);
}
@ -1093,7 +1098,14 @@ export class PostgresAssessorRepository implements AssessorRepository {
SELECT evaluation.*,
row_number() OVER (
PARTITION BY evaluation.aggregation_run_id
ORDER BY jsonb_array_length(evaluation.data->'run'->'baselineAnalysisIds') DESC,
-- Match selectCurrentPolicyEvaluations: multiple analysis generations
-- of the same compared baseline do not increase benchmark coverage.
-- Only fall back to declared references when no result names a baseline.
ORDER BY COALESCE(NULLIF((
SELECT count(DISTINCT NULLIF(compared.data->>'baselineId', ''))
FROM assessor_policy_evaluation_results compared
WHERE compared.evaluation_run_id = evaluation.id
), 0), jsonb_array_length(evaluation.data->'run'->'baselineAnalysisIds')) DESC,
evaluation.completed_at DESC, evaluation.id DESC
) AS run_rank
FROM assessor_policy_evaluation_runs evaluation
@ -2053,7 +2065,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
});
}
async failIngestionJob(jobId: string, workerId: string, failedAt: string, errorCode: string, nextAvailableAt: string): Promise<IngestionJob> {
async failIngestionJob(jobId: string, workerId: string, failedAt: string, errorCode: string, nextAvailableAt: string, retryable = true): Promise<IngestionJob> {
return this.mutate(async client => {
const selected = await client.query<DataRow<IngestionJob>>('SELECT data FROM assessor_ingestion_jobs WHERE id = $1 FOR UPDATE', [jobId]);
const current = selected.rows[0]?.data;
@ -2063,7 +2075,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
if (current.status !== 'running' || current.leaseOwner !== workerId || !errorCode.trim()
|| !Number.isFinite(failed) || !Number.isFinite(nextAvailable) || nextAvailable < failed
|| failed > Date.parse(current.leaseExpiresAt ?? '')) throw new RepositoryConflictError('Job-Fehler kann ohne gültigen Lease und Retry-Zeitpunkt nicht gespeichert werden.');
const exhausted = current.attempts >= current.maxAttempts;
const exhausted = !retryable || current.attempts >= current.maxAttempts;
const updated: IngestionJob = {
...current,
status: exhausted ? 'dead-letter' : 'queued',
@ -2187,9 +2199,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
}
async saveReview(review: FindingReview, expectedRevision?: number): Promise<void> {
await this.mutate(async client => {
const existing = await client.query<{ revision: number } & QueryResultRow>(
'SELECT revision FROM assessor_reviews WHERE id = $1 FOR UPDATE', [review.id]
const existing = await client.query<{ revision: number; data: FindingReview } & QueryResultRow>(
'SELECT revision, data FROM assessor_reviews WHERE id = $1 FOR UPDATE', [review.id]
);
assertRiskAcceptanceUnchanged(existing.rows[0]?.data, review);
const currentRevision = existing.rows[0]?.revision ?? 0;
if (expectedRevision !== undefined && expectedRevision !== currentRevision) throw new RepositoryConflictError(`Review ${review.id} wurde zwischenzeitlich geändert.`);
if (review.revision !== currentRevision + 1) throw new RepositoryValidationError(`Reviewrevision muss ${currentRevision + 1} sein.`);
@ -2206,6 +2219,26 @@ export class PostgresAssessorRepository implements AssessorRepository {
});
}
async recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<FindingReview> {
return this.mutate(async client => {
// Lock the occurrence as well: two first decisions must not create parallel review histories.
const target = await client.query(`SELECT o.id FROM assessor_finding_occurrences o
JOIN assessor_scan_runs r ON r.id=o.scan_run_id JOIN assessor_assessments a ON a.id=r.assessment_id
WHERE o.id=$1 AND a.project_id=$2 FOR UPDATE OF o`, [occurrenceId, projectId]);
if (!target.rowCount) throw new RepositoryNotFoundError('Finding is not in this project.');
const existing = await client.query<DataRow<FindingReview>>(`SELECT data FROM assessor_reviews
WHERE occurrence_id=$1 ORDER BY revision DESC, updated_at DESC, id LIMIT 1 FOR UPDATE`, [occurrenceId]);
const current = existing.rows[0]?.data;
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== (current?.revision ?? 0)) throw new RepositoryConflictError('The review changed. Reload it before recording a decision.');
const review = appendRiskAcceptanceReview(current, occurrenceId, command, new Date().toISOString());
await client.query(`INSERT INTO assessor_reviews (id, target_kind, definition_id, occurrence_id, revision, updated_at, data)
VALUES ($1, 'occurrence', NULL, $2, $3, $4, $5::jsonb)
ON CONFLICT (id) DO UPDATE SET revision=EXCLUDED.revision, updated_at=EXCLUDED.updated_at, data=EXCLUDED.data`,
[review.id, occurrenceId, review.revision, review.updatedAt, json(review)]);
return review;
});
}
async listReviews(projectId: string): Promise<FindingReview[]> {
return dataRows(await this.pool.query<DataRow<FindingReview>>(`
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv

View File

@ -1,3 +1,4 @@
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord } from '../domain/canonical-model';
import type {
UnifiedResultCategory,
@ -174,6 +175,9 @@ export class AssessorApiClient {
saveReview(review: FindingReview, expectedRevision?: number): Promise<{ review: FindingReview; repositoryRevision: number }> {
return this.request('/reviews', { method: 'POST', body: JSON.stringify({ review, expectedRevision }) });
}
recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<{ review: FindingReview; repositoryRevision: number }> {
return this.request(`/projects/${encodeURIComponent(projectId)}/risk-acceptances`, { method: 'POST', body: JSON.stringify({ occurrenceId, command, expectedRevision }) });
}
queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
return this.request(unifiedQueryPath('/results', query));
@ -223,11 +227,11 @@ export class AssessorApiClient {
});
}
async submitGpoBackup(projectId: string, file: File, assessmentId?: string): Promise<GpoBackupSubmissionResponse> {
async submitGpoBackup(projectId: string, file: File, assessmentId?: string, restoreArtifactId?: string): Promise<GpoBackupSubmissionResponse> {
const bytes = new Uint8Array(await file.arrayBuffer());
return this.request(`/projects/${encodeURIComponent(projectId)}/gpo-backups`, {
method: 'POST',
body: JSON.stringify({ fileName: file.name, sizeBytes: file.size, bytesBase64: bytesToBase64(bytes), assessmentId })
body: JSON.stringify({ fileName: file.name, sizeBytes: file.size, bytesBase64: bytesToBase64(bytes), assessmentId, restoreArtifactId })
});
}
listProjectGpoBackupStatus(projectId: string): Promise<GpoBackupStatusResponse> {

View File

@ -85,6 +85,7 @@ export interface ImportBatchCompleteResponse {
}
export interface GpoBackupSubmissionRequest {
restoreArtifactId?: string;
fileName: string;
sizeBytes: number;
bytesBase64: string;
@ -92,6 +93,7 @@ export interface GpoBackupSubmissionRequest {
}
export interface GpoBackupSubmissionResponse {
sourceRestored?: boolean;
version: 1;
artifactId: string;
jobId: string;
@ -122,9 +124,21 @@ export interface GpoBackupReprocessResponse {
status: 'queued' | 'running' | 'succeeded' | 'dead-letter';
supersedesProcessingRunId: string;
}
export type GpoBackupDisplayState = 'queued' | 'running' | 'succeeded' | 'partial' | 'dead-letter' | 'recovery-required';
export type GpoBackupDisplayState = 'queued' | 'running' | 'succeeded' | 'partial' | 'dead-letter' | 'recovery-required' | 'upload-required' | 'storage-unavailable';
/** A newer GPO analyzer changes retained parsing output, so the source must be reprocessed. */
export interface GpoAnalysisUpdate {
identityAnalysisRunId: string;
expectedLatestProcessingRunId: string;
previousAnalyzerVersion: string;
currentAnalyzerVersion: string;
}
export interface GpoBackupStatusRecord {
/** Existing input can be re-evaluated; this is distinct from source reimport. */
evaluationUpdates?: import('../domain/policy-analysis-overview').GpoEvaluationUpdate[];
/** Retained parsing output is outdated; reprocessing the stored source is required. */
analysisUpdate?: GpoAnalysisUpdate;
version: 1;
artifactId: string;
assessmentId?: string;
@ -134,6 +148,7 @@ export interface GpoBackupStatusRecord {
submittedAt: string;
updatedAt: string;
state: GpoBackupDisplayState;
jobStatus?: 'queued' | 'running' | 'succeeded' | 'dead-letter';
attempts: number;
maxAttempts: number;
lastErrorCode?: string;

View File

@ -18,7 +18,7 @@ import { exportSnapshot } from '../exports/snapshot-export';
import { browserDownloadAdapter } from '../platform/browser/browser-download-adapter';
import assessorLogo from '../assets/branding/assessor-icon-transparent.png';
import { TriangleIcon } from '../components/TriangleIcon';
import { SidebarSeverityMetrics } from '../features/sidebar/SidebarSeverityMetrics';
import { SidebarSecurityDomains } from '../features/sidebar/SidebarSecurityDomains';
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';
@ -76,6 +76,12 @@ export function App() {
setView('records');
setActiveArea('work');
}, [setView]);
const openWorkDefault = useCallback(() => {
const hasIncomingFilter = Boolean(app.search.trim() || Object.keys(app.facetFilters).length
|| app.pathFindingIds.length || app.actionOnly);
if (!hasIncomingFilter) app.resetFilters();
openWork();
}, [app.actionOnly, app.facetFilters, app.pathFindingIds, app.resetFilters, app.search, openWork]);
const marked = useMemo(() => Object.values(reviews).filter(review => review.include).length, [reviews]);
@ -138,8 +144,8 @@ export function App() {
</header>
<main>
<aside><div className="app-sidebar">
<nav className="sidebar-primary-actions" aria-label="Main navigation">{NAV_AREAS.map(([area, label]) => <button className={`sidebar-tab-btn${activeArea === area ? ' active' : ''}`} key={area} type="button" disabled={area !== 'data-hub' && area !== 'db-structure' && !targetProjectId} onClick={() => area === 'work' ? openWork() : setActiveArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav>
{activeArea === 'work' && <SidebarSeverityMetrics sheet={activeSheet}/>}
<nav className="sidebar-primary-actions" aria-label="Main navigation">{NAV_AREAS.map(([area, label]) => <button className={`sidebar-tab-btn${activeArea === area ? ' active' : ''}`} key={area} type="button" disabled={area !== 'data-hub' && area !== 'db-structure' && !targetProjectId} onClick={() => area === 'work' ? openWorkDefault() : setActiveArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav>
{activeArea === 'work' && <SidebarSecurityDomains sheet={activeSheet}/>}
{/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht
einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */}
<section className="sidebar-theme-panel"><div className="theme-switcher" role="group" aria-label="Controls and color scheme">
@ -171,13 +177,13 @@ export function App() {
onDetailViewChanged={handleDataHubDetailViewChanged}
onProjectDataChanged={async projectId => {
await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined,
projectId === targetProjectId ? app.activeAssessmentIds : undefined);
projectId === targetProjectId ? app.activeAssessmentIds : undefined, activeProjectName);
}}
onProjectSelected={(project, scanRunIds, assessmentIds) => {
setActiveProjectName(project?.name);
if (project) {
setActiveArea('dashboard');
void app.openProject(project.id, scanRunIds, assessmentIds);
void app.openProject(project.id, scanRunIds, assessmentIds, project.name);
} else app.setTargetProjectId(undefined);
}}
/>

View File

@ -2,6 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { AssessorApiClient } from '../api/assessor-api-client';
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
import type { FindingReview } from '../domain/canonical-model';
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
import { defaultReview } from '../domain/review';
import type { ImportProfile } from '../domain/import-profile';
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
import type { Review } from '../domain/review';
@ -42,6 +44,7 @@ interface AppContextValue extends AppState {
commitImportBatch(): Promise<void>;
closeImportWorkflow(): void;
setActiveSheet(sheetId: string): void;
recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise<void>;
setView(view: ViewMode): void;
setSearch(search: string): void;
setSortColumn(column: string): void;
@ -57,7 +60,7 @@ interface AppContextValue extends AppState {
activeAssessmentIds?: string[];
workScopeId: string;
setTargetProjectId: (projectId?: string) => void;
openProject(projectId: string, scanRunIds?: string[], assessmentIds?: string[]): Promise<boolean>;
openProject(projectId: string, scanRunIds?: string[], assessmentIds?: string[], clientName?: string): Promise<boolean>;
setSelectedRecord(recordId: string): void;
updateReview(recordId: string, patch: Partial<Review>): void;
snapshot(): ReturnType<typeof createSnapshot> | null;
@ -113,6 +116,10 @@ export function profileForSheet(dataset: ImportedDataset | null, sheet?: Dataset
: profileFor(dataset);
}
function initialSheetStatus(_sheet: DatasetSheet | undefined, _defaultStatus: string): string {
return 'deviation';
}
function effectiveTheme(mode: ThemeMode): 'dark' | 'light' {
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
@ -125,6 +132,7 @@ export function AppProvider({ children }: PropsWithChildren) {
const [activeAssessmentIds, setActiveAssessmentIds] = useState<string[] | undefined>();
const [workScopeId, setWorkScopeId] = useState('');
const openProjectRequest = useRef(0);
const openProjectIdentity = useRef<{ id: string; name: string } | undefined>(undefined);
const persistedReviews = useRef(new Map<string, FindingReview>());
const reviewSaveQueues = useRef(new Map<string, Promise<void>>());
const [state, setState] = useState<AppState>(() => {
@ -160,7 +168,7 @@ export function AppProvider({ children }: PropsWithChildren) {
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());
const review = reviewToDatabase({ ...desired, riskAcceptance: current?.riskAcceptance }, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString());
await assessorApi.saveReview(review, current?.revision ?? 0);
persistedReviews.current.set(reviewTargetKey(review.target), review);
}).catch(() => {
@ -199,6 +207,7 @@ export function AppProvider({ children }: PropsWithChildren) {
const importFiles = useCallback(async (fileCollection: FileList | File[], targetProjectId?: string, targetAssessmentId?: string, replacesScanRunId?: string) => {
const files = [...fileCollection];
if (!files.length) return;
let preparedPreview: ImportBatchPreview | undefined;
setImportWorkflow({ phase: 'preparing', targetProjectId, targetAssessmentId, replacesScanRunId });
setState(current => ({ ...current, status: `Checking ${files.length === 1 ? files[0].name : `${files.length} files`}...` }));
try {
@ -216,7 +225,7 @@ export function AppProvider({ children }: PropsWithChildren) {
...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews,
projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
statusFilter: current.statusFilter,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', actionOnly: false, view: 'columns',
status: 'Snapshot loaded'
}));
@ -266,6 +275,7 @@ export function AppProvider({ children }: PropsWithChildren) {
reimport: Boolean(replacesScanRunId), singleFile: files.length === 1
});
const reviewPreview: ImportBatchPreview = { ...preview, entries: entriesToProcess };
preparedPreview = reviewPreview;
const selected = entriesToProcess.filter(entry => entry.selected && entry.dataset && entry.status !== 'error');
const hasErrors = entriesToProcess.some(entry => entry.status === 'error');
@ -320,7 +330,7 @@ export function AppProvider({ children }: PropsWithChildren) {
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
statusFilter: current.statusFilter,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records`
}));
setWorkScopeId(projectId);
@ -334,8 +344,15 @@ export function AppProvider({ children }: PropsWithChildren) {
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(error);
setImportWorkflow(current => enterImportPreview(current, { version: 1, batchId: `failed-${Date.now()}`, createdAt: new Date().toISOString(), entries: [] }, message));
setState(current => ({ ...current, status: `Import check failed: ${message}` }));
const failedBeforePreview = !preparedPreview;
setImportWorkflow(current => enterImportPreview(
current,
preparedPreview ?? { version: 1, batchId: `failed-${Date.now()}`, createdAt: new Date().toISOString(), entries: [] },
failedBeforePreview ? message : `Import not executed: ${message}`
));
setState(current => ({ ...current, status: failedBeforePreview
? `Import check failed: ${message}`
: `Import not executed: ${message}` }));
}
}, [state.backendStatus, state.delimiter]);
@ -424,7 +441,7 @@ export function AppProvider({ children }: PropsWithChildren) {
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
statusFilter: current.statusFilter,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records`
}));
setWorkScopeId(projectId);
@ -439,9 +456,13 @@ export function AppProvider({ children }: PropsWithChildren) {
}, [importWorkflow.preview, state.backendStatus]);
const openProject = useCallback(async (projectId: string, scanRunIds?: string[], selectedAssessmentIds?: string[]): Promise<boolean> => {
const openProject = useCallback(async (projectId: string, scanRunIds?: string[], selectedAssessmentIds?: string[], clientName?: string): Promise<boolean> => {
const requestId = ++openProjectRequest.current;
const scopeId = createWorkScopeId(projectId, scanRunIds, selectedAssessmentIds);
const loadingClientName = clientName?.trim()
|| (openProjectIdentity.current?.id === projectId ? openProjectIdentity.current.name : '')
|| projectId;
openProjectIdentity.current = { id: projectId, name: loadingClientName };
setTargetProjectId(projectId);
// Der fachliche Scope wechselt atomar mit dem Client-/Timeslot-Auftrag.
// Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens
@ -453,11 +474,12 @@ export function AppProvider({ children }: PropsWithChildren) {
dataset: null,
activeSheetId: '',
projectId,
status: 'Loading saved client …'
status: `Loading Client: ${loadingClientName}`
}));
try {
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds);
if (requestId !== openProjectRequest.current) return false;
if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name };
if (!models.length) {
persistedReviews.current = new Map();
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
@ -491,7 +513,7 @@ export function AppProvider({ children }: PropsWithChildren) {
titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter: defaults.statusFilter,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {},
pathFindingIds: [],
status: models[0].project.name + ' · ' + dataset.scanTool + ' · ' + models.length.toLocaleString('en-US') + ' scan report' + (models.length === 1 ? '' : 's') + ' · ' + activeSheet.analysis.records.length.toLocaleString('en-US') + ' records loaded'
@ -549,7 +571,7 @@ export function AppProvider({ children }: PropsWithChildren) {
titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter: defaults.statusFilter,
statusFilter: initialSheetStatus(sheet, defaults.statusFilter),
facetFilters: {},
pathFindingIds: []
};
@ -584,6 +606,7 @@ export function AppProvider({ children }: PropsWithChildren) {
setTargetProjectId: projectId => {
setTargetProjectId(projectId);
if (!projectId) {
openProjectIdentity.current = undefined;
setActiveScanRunIds(undefined);
setActiveAssessmentIds(undefined);
}
@ -591,6 +614,36 @@ export function AppProvider({ children }: PropsWithChildren) {
openProject,
setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })),
updateReview,
recordRiskAcceptance: async (recordId, command) => {
const record = state.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(item => item.id === recordId);
if (!targetProjectId || !record?.findingOccurrenceId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved finding before recording risk acceptance.');
const occurrenceId = record.findingOccurrenceId;
const key = `occurrence:${occurrenceId}`;
const persisted = persistedReviews.current;
const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve();
const queued = previous.catch(() => undefined).then(async () => {
let current = persisted.get(key);
const desired = { ...defaultReview(), ...state.reviews[recordId], riskAcceptance: current?.riskAcceptance };
if (!current || !reviewsEqual(desired, reviewFromDatabase(current))) {
const review = reviewToDatabase(desired, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString());
const saved = await assessorApi.saveReview(review, current?.revision ?? 0);
current = saved.review;
persisted.set(key, current);
}
const saved = await assessorApi.recordRiskAcceptance(targetProjectId, occurrenceId, command, current.revision);
persisted.set(key, saved.review);
setState(latest => {
if (latest.dataset !== state.dataset) return latest;
const reviews = { ...latest.reviews };
for (const id of recordsByOccurrence(latest.dataset!).get(occurrenceId) ?? []) {
reviews[id] = { ...defaultReview(), ...reviews[id], riskAcceptance: saved.review.riskAcceptance };
}
return { ...latest, reviews };
});
});
reviewSaveQueues.current.set(key, queued);
return queued;
},
snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null,
setTheme,
setDelimiter: delimiter => setState(current => ({ ...current, delimiter })),
@ -599,6 +652,7 @@ export function AppProvider({ children }: PropsWithChildren) {
resetFilters: () => setState(current => {
const profile = profileForSheet(current.dataset, activeSheet);
const defaults = profile.defaults(activeSheet);
const statusFilter = initialSheetStatus(activeSheet, defaults.statusFilter);
// Der gerade gelesene Fund bleibt ausgewaehlt, solange er im
// Standardfilter vorkommt - sonst sprang die Ansicht auf den ersten
// Datensatz und man verlor die Stelle, an der man gerade gearbeitet hat.
@ -610,7 +664,7 @@ export function AppProvider({ children }: PropsWithChildren) {
search: '',
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter: defaults.statusFilter,
statusFilter,
facetFilters: {},
dataset: current.dataset,
actionOnly: current.actionOnly,
@ -623,7 +677,7 @@ export function AppProvider({ children }: PropsWithChildren) {
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
titleColumn: defaults.titleColumn,
statusFilter: defaults.statusFilter,
statusFilter,
facetFilters: {},
pathFindingIds: [],
selectedRecordId: keepsSelection ? current.selectedRecordId : (visible[0]?.id ?? '')

View File

@ -30,7 +30,7 @@ export interface AppState {
export type SavedViewState = Pick<AppState,
'activeSheetId' | 'view' | 'search' | 'sortColumn' | 'sortDirection' |
'titleColumn' | 'statusFilter' | 'facetFilters' | 'actionOnly' | 'selectedRecordId'
'titleColumn' | 'statusFilter' | 'facetFilters' | 'pathFindingIds' | 'actionOnly' | 'selectedRecordId'
>;
export function applySavedViewState(current: AppState, savedView: SavedViewState): AppState {
@ -39,7 +39,7 @@ export function applySavedViewState(current: AppState, savedView: SavedViewState
id,
{ ...filter, ...(filter.values ? { values: [...filter.values] } : {}) }
]));
return { ...current, ...savedView, facetFilters, pathFindingIds: [] };
return { ...current, ...savedView, facetFilters, pathFindingIds: [...savedView.pathFindingIds] };
}

View File

@ -0,0 +1,252 @@
import type { CanonicalAssessmentModel, FindingOccurrence, ImportFileRecord, ScanRun } from '../domain/canonical-model';
import type { FindingComparisonIdentityResult, RemediationEvidenceState } from '../domain/timeslot-comparison';
import {
comparisonObservationTime,
comparisonScopeIdentity,
evaluateRemediationEvidence,
findingComparisonIdentity
} from '../domain/timeslot-comparison';
import type { ToolTimeslotComparisonDefinition } from '../domain/tool-profile-definition';
import { isDeviation } from '../domain/deviation-predicate';
import { outcomeFromLegacyStatus } from '../domain/unified-result';
import { resolvedCompatibilityKey } from '../domain/processing-compatibility';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
export interface TimeslotOccurrenceDecision {
baselineOccurrenceId: string;
targetOccurrenceId?: string;
identityKey?: string;
state: RemediationEvidenceState;
reasons: string[];
}
export interface TimeslotSourceComparison {
toolId: string;
baselineRunId?: string;
targetRunId?: string;
state: 'compared' | 'not-comparable';
reasons: string[];
decisions: TimeslotOccurrenceDecision[];
newDeviationOccurrenceIds: string[];
}
export interface ProjectTimeslotEvidenceComparison {
version: '1.0.0';
projectId: string;
baselineRunIds: string[];
targetRunIds: string[];
sources: TimeslotSourceComparison[];
summary: {
baselineDeviations: number;
verifiedRemediated: number;
confirmedOpen: number;
unverifiedAbsent: number;
notComparable: number;
notApplicable: number;
newDeviations: number;
};
}
const normalizedTool = (value: string): string => value.trim().toLocaleLowerCase('en-US');
function occurrenceOutcome(occurrence: FindingOccurrence) {
return occurrence.outcome?.normalizedValue
?? outcomeFromLegacyStatus(occurrence.status.normalizedValue ?? 'unknown');
}
function filesCompatibility(left: ImportFileRecord[], right: ImportFileRecord[]): { compatible: boolean; reasons: string[] } {
if (!left.length || !right.length) return { compatible: false, reasons: ['processing-provenance-missing'] };
const keys = (files: ImportFileRecord[]) => [...new Set(files.map(file =>
resolvedCompatibilityKey(file.detectedTool.id || file.processedWith.profileId, file.processedWith)
))];
const leftKeys = keys(left);
const rightKeys = keys(right);
if (leftKeys.some(key => !key) || rightKeys.some(key => !key)) {
return { compatible: false, reasons: ['processing-contract-unknown'] };
}
if (leftKeys.length !== 1 || rightKeys.length !== 1 || leftKeys[0] !== rightKeys[0]) {
return { compatible: false, reasons: ['processing-contract-incompatible'] };
}
return { compatible: true, reasons: [] };
}
function identityFor(
occurrence: FindingOccurrence,
definition: Exclude<ToolTimeslotComparisonDefinition, { kind: 'unavailable' }>,
scope: { kind: string; key: string }
): FindingComparisonIdentityResult {
return findingComparisonIdentity({
occurrence,
granularity: definition.granularity,
scope,
...(definition.granularity === 'asset' ? {
object: {
kind: definition.object.kind,
namespace: definition.object.namespace,
key: typeof occurrence.affectedAsset?.normalizedValue === 'string'
? occurrence.affectedAsset.normalizedValue : ''
}
} : {})
});
}
function unmatchedDecisions(occurrences: FindingOccurrence[], reasons: string[]): TimeslotOccurrenceDecision[] {
return occurrences.filter(item => isDeviation(Boolean(item.isIoe), occurrenceOutcome(item))).map(item => ({
baselineOccurrenceId: item.id, state: 'not-comparable', reasons
}));
}
/**
* Conservative A/B remediation comparison. Scanner runs remain separate and
* retain their file, processing, scope and observation-time provenance.
*/
export function compareProjectTimeslotEvidence(
models: CanonicalAssessmentModel[],
baselineRunIds: string[],
targetRunIds: string[]
): ProjectTimeslotEvidenceComparison {
if (!models.length) throw new Error('Timeslot comparison requires project data.');
const projectId = models[0].project.id;
if (models.some(model => model.project.id !== projectId)) throw new Error('Timeslots must belong to the same project.');
if (!baselineRunIds.length || !targetRunIds.length) throw new Error('Both timeslots require scanner runs.');
if (baselineRunIds.some(id => targetRunIds.includes(id))) throw new Error('Baseline and target timeslots must use different runs.');
const runs = models.flatMap(model => model.scanRuns);
const runById = new Map(runs.map(run => [run.id, run]));
const missing = [...baselineRunIds, ...targetRunIds].filter(id => !runById.has(id));
if (missing.length) throw new Error('At least one selected scanner run is unavailable.');
const selected = (ids: string[]) => ids.map(id => runById.get(id)!);
const byTool = (items: ScanRun[]) => {
const map = new Map<string, ScanRun[]>();
items.forEach(run => {
const toolId = normalizedTool(run.tool.id);
map.set(toolId, [...(map.get(toolId) ?? []), run]);
});
return map;
};
const baselineByTool = byTool(selected(baselineRunIds));
const targetByTool = byTool(selected(targetRunIds));
const toolIds = [...new Set([...baselineByTool.keys(), ...targetByTool.keys()])].sort();
const occurrences = models.flatMap(model => model.findingOccurrences);
const files = models.flatMap(model => model.importFiles);
const observations = models.flatMap(model => model.environmentObservations ?? []);
const diagnostics = models.flatMap(model => model.diagnostics);
const sources = toolIds.map((toolId): TimeslotSourceComparison => {
const baselineRuns = baselineByTool.get(toolId) ?? [];
const targetRuns = targetByTool.get(toolId) ?? [];
const baselineRun = baselineRuns.length === 1 ? baselineRuns[0] : undefined;
const targetRun = targetRuns.length === 1 ? targetRuns[0] : undefined;
const baselineOccurrences = baselineRun ? occurrences.filter(item => item.scanRunId === baselineRun.id) : [];
const profile = toolProfileDefinitions.find(item => item.id === toolId);
const pairingReasons = [
...(baselineRuns.length !== 1 ? [baselineRuns.length ? 'baseline-source-ambiguous' : 'baseline-source-missing'] : []),
...(targetRuns.length !== 1 ? [targetRuns.length ? 'target-source-ambiguous' : 'target-source-missing'] : []),
...(!profile ? ['comparison-profile-missing'] : []),
...(profile?.timeslotComparison.kind === 'unavailable' ? ['comparison-profile-unavailable'] : [])
];
if (!baselineRun || !targetRun || !profile || profile.timeslotComparison.kind === 'unavailable') return {
toolId, baselineRunId: baselineRun?.id, targetRunId: targetRun?.id,
state: 'not-comparable', reasons: pairingReasons,
decisions: unmatchedDecisions(baselineOccurrences, pairingReasons), newDeviationOccurrenceIds: []
};
const definition = profile.timeslotComparison;
const baselineFiles = files.filter(item => item.scanRunId === baselineRun.id);
const targetFiles = files.filter(item => item.scanRunId === targetRun.id);
const compatibility = filesCompatibility(baselineFiles, targetFiles);
const baselineObservations = observations.filter(item => item.scanRunId === baselineRun.id && normalizedTool(item.tool.id) === toolId);
const targetObservations = observations.filter(item => item.scanRunId === targetRun.id && normalizedTool(item.tool.id) === toolId);
const baselineScope = comparisonScopeIdentity(definition, baselineObservations);
const targetScope = comparisonScopeIdentity(definition, targetObservations);
const baselineTime = comparisonObservationTime(definition, baselineObservations);
const targetTime = comparisonObservationTime(definition, targetObservations);
const sourceReasons = [
...compatibility.reasons,
...(baselineScope.state === 'unverified' ? baselineScope.reasons : []),
...(targetScope.state === 'unverified' ? targetScope.reasons : []),
...(baselineTime.state === 'unverified' ? [baselineTime.reason] : []),
...(targetTime.state === 'unverified' ? [targetTime.reason] : [])
];
if (baselineScope.state === 'verified' && targetScope.state === 'verified'
&& baselineScope.scope.key !== targetScope.scope.key) sourceReasons.push('comparison-scope-changed');
const baselineFileIds = new Set(baselineFiles.map(item => item.id));
const targetFileIds = new Set(targetFiles.map(item => item.id));
const blockingDiagnostics = diagnostics
.filter(item => item.severity !== 'info' && (baselineFileIds.has(item.importFileId) || targetFileIds.has(item.importFileId)))
.map(item => `diagnostic:${item.code}`);
const sourceComparable = sourceReasons.length === 0;
if (!sourceComparable || baselineScope.state !== 'verified' || targetScope.state !== 'verified'
|| baselineTime.state !== 'verified' || targetTime.state !== 'verified') return {
toolId, baselineRunId: baselineRun.id, targetRunId: targetRun.id,
state: 'not-comparable', reasons: [...new Set(sourceReasons)],
decisions: unmatchedDecisions(baselineOccurrences, [...new Set(sourceReasons)]), newDeviationOccurrenceIds: []
};
const targetOccurrences = occurrences.filter(item => item.scanRunId === targetRun.id);
const targetIdentities = targetOccurrences.map(item => ({ item, identity: identityFor(item, definition, targetScope.scope) }));
const targetByIdentity = new Map<string, typeof targetIdentities>();
targetIdentities.forEach(entry => {
if (entry.identity.state !== 'verified') return;
const key = entry.identity.identity.key;
targetByIdentity.set(key, [...(targetByIdentity.get(key) ?? []), entry]);
});
const baselineDeviations = baselineOccurrences.filter(item => isDeviation(Boolean(item.isIoe), occurrenceOutcome(item)));
const baselineIdentities = baselineDeviations.map(item => ({ item, identity: identityFor(item, definition, baselineScope.scope) }));
const baselineCounts = new Map<string, number>();
baselineIdentities.forEach(entry => {
if (entry.identity.state === 'verified') baselineCounts.set(entry.identity.identity.key, (baselineCounts.get(entry.identity.identity.key) ?? 0) + 1);
});
const decisions = baselineIdentities.map(({ item, identity }): TimeslotOccurrenceDecision => {
if (identity.state !== 'verified') return {
baselineOccurrenceId: item.id, state: 'not-comparable', reasons: identity.reasons
};
const matches = targetByIdentity.get(identity.identity.key) ?? [];
if ((baselineCounts.get(identity.identity.key) ?? 0) !== 1 || matches.length > 1) return {
baselineOccurrenceId: item.id, identityKey: identity.identity.key,
state: 'not-comparable', reasons: ['comparison-identity-ambiguous']
};
const target = matches.length === 1 ? matches[0] : undefined;
const decision = evaluateRemediationEvidence({
baselineIdentity: identity,
targetIdentity: target?.identity,
baselineObservedAt: baselineTime.observedAt,
targetObservedAt: targetTime.observedAt,
processingCompatible: compatibility.compatible,
targetOutcome: target ? occurrenceOutcome(target.item) : undefined,
targetIsIoe: target?.item.isIoe,
coverage: definition.resultEvidence === 'explicit-results' ? 'complete' : 'unknown',
absenceSemantics: definition.negativeEvidence === 'complete-explicit-negative' ? 'explicit-negative' : 'not-reported',
blockingDiagnostics
});
return {
baselineOccurrenceId: item.id, ...(target ? { targetOccurrenceId: target.item.id } : {}),
identityKey: decision.identityKey, state: decision.state, reasons: decision.reasons
};
});
const baselineKeys = new Set(baselineIdentities.flatMap(entry => entry.identity.state === 'verified' ? [entry.identity.identity.key] : []));
const newDeviationOccurrenceIds = targetIdentities.flatMap(entry => {
if (entry.identity.state !== 'verified' || !isDeviation(Boolean(entry.item.isIoe), occurrenceOutcome(entry.item))) return [];
if ((targetByIdentity.get(entry.identity.identity.key) ?? []).length !== 1) return [];
return baselineKeys.has(entry.identity.identity.key) ? [] : [entry.item.id];
});
return {
toolId, baselineRunId: baselineRun.id, targetRunId: targetRun.id,
state: 'compared', reasons: [...new Set(blockingDiagnostics)], decisions, newDeviationOccurrenceIds
};
});
const decisions = sources.flatMap(source => source.decisions);
return {
version: '1.0.0', projectId, baselineRunIds: [...baselineRunIds], targetRunIds: [...targetRunIds], sources,
summary: {
baselineDeviations: decisions.length,
verifiedRemediated: decisions.filter(item => item.state === 'verified-remediated').length,
confirmedOpen: decisions.filter(item => item.state === 'confirmed-open').length,
unverifiedAbsent: decisions.filter(item => item.state === 'unverified-absent').length,
notComparable: decisions.filter(item => item.state === 'not-comparable').length,
notApplicable: decisions.filter(item => item.state === 'not-applicable').length,
newDeviations: sources.reduce((sum, source) => sum + source.newDeviationOccurrenceIds.length, 0)
}
};
}

View File

@ -0,0 +1,29 @@
import { useState } from 'react';
import { Dialog } from '../Dialog/Dialog';
type BackendStatus = 'checking' | 'unavailable';
interface BackendConnectionDialogProps {
backendStatus: BackendStatus;
message: string;
}
export function BackendConnectionDialog({ backendStatus, message }: BackendConnectionDialogProps) {
const [open, setOpen] = useState(true);
if (!open) return null;
const title = backendStatus === 'checking'
? 'Checking server connection'
: 'Backend is unreachable';
return <Dialog title={title} onClose={() => setOpen(false)} className="backend-connection-dialog">
<div className="backend-connection-dialog__content" role="status" aria-live="polite">
<div className="backend-connection-dialog__icon" aria-hidden="true">!</div>
<div>
<p>{message}</p>
<p className="backend-connection-dialog__hint">The connection is checked automatically.</p>
</div>
</div>
</Dialog>;
}

View File

@ -5,12 +5,13 @@ interface Props extends PropsWithChildren {
title: ReactNode;
onClose(): void;
dismissible?: boolean;
className?: string;
}
export function Dialog({ title, onClose, dismissible = true, children }: Props) {
export function Dialog({ title, onClose, dismissible = true, className = '', children }: Props) {
const dialog = (
<div className="dialog-backdrop" role="presentation" onMouseDown={dismissible ? onClose : undefined}>
<section className="dialog-window" role="dialog" aria-modal="true" onMouseDown={event => event.stopPropagation()}>
<section className={`dialog-window ${className}`.trim()} role="dialog" aria-modal="true" aria-label={typeof title === 'string' ? title : undefined} onMouseDown={event => event.stopPropagation()}>
<header>
<h2>{title}</h2>
{dismissible && <button type="button" onClick={onClose} aria-label="Close window">×</button>}

View File

@ -41,6 +41,7 @@ export function DonutMetricCard({
const progressStyle = { '--donut-progress-dash': dashArray } as CSSProperties;
const classes = ['donut-metric-card', 'donut-metric-card--' + tone, className].filter(Boolean).join(' ');
const ariaLabel = label + ': ' + value.toLocaleString('en-US') + ', ' + boundedPercentage + '%';
const displayedValue = value.toLocaleString('en-US') + valueSuffix;
const content = <>
<h3 className="donut-metric-card__title">{label}</h3>
<div className="donut-metric-card__plot">
@ -49,11 +50,10 @@ export function DonutMetricCard({
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/>
</svg>
<div className="donut-metric-card__values">
<div className={`donut-metric-card__primary${boundedPercentage >= 100 ? ' is-compact' : ''}`}>
<strong>{showPercentage ? boundedPercentage : value.toLocaleString('en-US')}{showPercentage ? '' : valueSuffix}</strong>
{showPercentage && <span>%</span>}
<div className={`donut-metric-card__primary${displayedValue.length >= 5 ? ' is-compact' : ''}`}>
<strong>{displayedValue}</strong>
</div>
{showPercentage && <small>{value.toLocaleString('en-US')}{valueSuffix}</small>}
{showPercentage && <small>{boundedPercentage}%</small>}
</div>
</div>
</>;

View File

@ -1,8 +1,9 @@
import { memo, useEffect, useRef } from 'react';
import { memo } from 'react';
import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { policyDiagnosticBadges } from '../../profiles/group-policy/categories';
import { highlightText } from '../HighlightText/highlight-text';
@ -24,24 +25,21 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const severity = normalizeSeverity(severityField?.value);
const statusField = findingCardStatus(record, toolId);
const outcome = outcomeForStatusValue(toolIdForRecord(record, toolId), statusField?.value);
const cardRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!selected) return;
cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}, [selected]);
const resolvedToolId = toolIdForRecord(record, toolId);
const outcome = outcomeForStatusValue(resolvedToolId, statusField?.value);
const diagnostics = policyDiagnosticBadges(record, resolvedToolId);
return (
<button
ref={cardRef}
aria-current={selected ? 'true' : undefined}
className={`record-card severity-${severity}${bookmarked ? ' is-workspace' : ''}${selected ? ' active' : ''}`}
type="button"
onClick={onOpen}
>
<span className="record-title">{highlightText(title, search)}{bookmarked && <span className="action-pill">Workspace</span>}</span>
{statusField && <span className="record-meta outcome-mark" data-outcome={outcome}>{highlightText(statusField.value, search)}</span>}
{(statusField || diagnostics.length > 0) && <span className="record-meta-line">
{statusField && <span className="record-meta outcome-mark" data-outcome={outcome}>{highlightText(statusField.value, search)}</span>}
{diagnostics.map(diagnostic => <span className="record-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={diagnostic}>{diagnostic}</span>)}
</span>}
</button>
);

View File

@ -20,6 +20,10 @@ export function isWholePath(value: string): boolean {
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);
const titledRegistryPath = /^(registry(?:\s+values)?\s*\u00b7\s*)(.+)$/i.exec(value.trim());
if (titledRegistryPath && (isWholePath(titledRegistryPath[2]) || /^(?:MACHINE|USER)[\\/]/i.test(titledRegistryPath[2]))) {
return <>{highlightText(titledRegistryPath[1], search)}{renderPath(titledRegistryPath[2])}</>;
}
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]))) {

View File

@ -1,4 +1,5 @@
import type { ResultContractExposureState, ResultContractResultType } from './result-contract-mapping';
import type { RiskAcceptance } from './risk-acceptance';
import type { EnvironmentObservation, ScannerReportSection } from './environment-context';
import type { SecurityScoringPolicy } from './security-scoring';
@ -310,6 +311,7 @@ export interface FindingReview {
requiredAccess?: string;
nextStep?: string;
acceptanceEvidence?: string;
riskAcceptance?: RiskAcceptance;
markers: string[];
createdAt: string;
updatedAt: string;

View File

@ -1,5 +1,27 @@
import type { JsonValue } from './canonical-model';
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Read-only original observations, not a GPO execution order or effective endpoint state. */
export type PolicySourceDeviationField = 'operation' | 'data' | 'typeCode' | 'value';
export interface PolicySourceEvidence {
id: string;
backupKey?: string;
backupInstanceId?: string;
gpoId?: string;
nativeBackupId?: string;
sourceReference: string;
sequence?: number;
values: JsonValue[];
/** Presentation of a retained current comparison, never endpoint effectiveness. */
baselineRelation?: 'aligned' | 'conflicting' | 'review';
/** Per retained value, fields proven different from every current baseline variant; value is the safe whole-value fallback. */
deviationFields?: PolicySourceDeviationField[][];
/** Per retained collection value, configured members absent from every current baseline variant. */
deviationItems?: string[][];
}
export interface RecordField {
label: string;
value: string;
@ -15,6 +37,13 @@ export interface FindingRecord {
/** Exact source scope for native report metrics; never inferred from display names. */
scanRunId?: string;
importFileId?: string;
/** Presentation-only provenance of Fail comparisons, using the Processing tile's exact backup identity. */
policyFailingBackupSources?: Array<{ id: string; label: string }>;
policySourceEvidence?: PolicySourceEvidence[];
/** Individual baseline memberships of one rolled-up technical policy setting. */
policyBaselineLabels?: string[];
/** Original row identities allow earlier baseline-specific notes to remain readable. */
policyReviewMembers?: Array<{ id: string; baseline: string }>;
index: number;
title: string;
fields: RecordField[];

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.3.0'
version: '1.4.0'
} as const;
export interface MicrosoftSecurityBaselineSetting {
@ -26,7 +26,7 @@ export interface MicrosoftSecurityBaselineAnalysis {
id: string;
baselineId: string;
packageSha256: string;
analyzer: { id: typeof MICROSOFT_BASELINE_SETTINGS_ANALYZER.id; version: '1.0.0' | '1.1.0' | '1.2.0' | '1.3.0' };
analyzer: { id: typeof MICROSOFT_BASELINE_SETTINGS_ANALYZER.id; version: '1.0.0' | '1.1.0' | '1.2.0' | '1.3.0' | '1.4.0' };
analyzedAt: string;
status: 'complete' | 'partial';
admxCatalog: AdmxPolicyCatalog;
@ -46,7 +46,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
|| !['1.0.0', '1.1.0', '1.2.0', '1.3.0'].includes(analysis.analyzer.version)
|| !['1.0.0', '1.1.0', '1.2.0', '1.3.0', '1.4.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.');

View File

@ -171,7 +171,7 @@ function uniqueSorted(values: string[]): string[] {
function categoryFor(codes: string[], explicit?: PolicyAnalysisReviewCategory): PolicyAnalysisReviewCategory {
if (explicit) return explicit;
if (codes.some(code => code.includes('conflict'))) return 'conflict';
if (codes.some(code => code.includes('unresolved-identity'))) return 'unresolved-identity';
if (codes.some(code => code.includes('unresolved-identity') || code.startsWith('identity-resolver-'))) return 'unresolved-identity';
if (codes.some(code => code.includes('catalog-gap') || code.includes('catalog-missing'))) return 'catalog-gap';
if (codes.some(code => code.includes('parser-gap') || code.includes('unsupported-setting'))) return 'parser-gap';
if (codes.some(code => code.includes('targeting'))) return 'targeting-unknown';
@ -218,7 +218,7 @@ function latestByKey<T>(
return [...latest.values()];
}
function activePolicyRecords(input: BuildPolicyAnalysisOverviewInput): {
function activePolicyRecords(input: Pick<BuildPolicyAnalysisOverviewInput, 'identityAnalyses' | 'profiles' | 'aggregations' | 'evaluations' | 'findingRuns'>): {
identityAnalyses: StoredGpoPolicyAnalysis[];
aggregations: StoredPolicySnapshotAggregation[];
evaluations: StoredPolicyEvaluation[];
@ -260,6 +260,111 @@ function activePolicyRecords(input: BuildPolicyAnalysisOverviewInput): {
return { identityAnalyses, aggregations, evaluations, findingRuns };
}
export type GpoEvaluationUpdateReason = 'engine-version' | 'baseline-analysis';
export interface GpoEvaluationUpdate {
evaluationRunId: string;
aggregationRunId: string;
baselineAnalysisIds: string[];
artifactIds: string[];
previousVersion: string;
currentVersion: string;
/** Why the retained evaluation is no longer current. Never empty. */
reasons: GpoEvaluationUpdateReason[];
}
/** Ordered comparison of dotted analyzer versions; absent parts count as zero. */
function compareAnalyzerVersions(left: string, right: string): number {
const parts = (value: string) => value.split('.').map(part => Number.parseInt(part, 10));
const a = parts(left);
const b = parts(right);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const difference = (a[index] || 0) - (b[index] || 0);
if (difference) return difference;
}
return 0;
}
/**
* Independently analysed baseline packages stay independent benchmarks. Only analyses of
* the very same package are one lineage, and an interrupted supersession chain can leave
* several of its generations unsuperseded. Their package-derived timestamp is identical
* and cannot order them, so the analyzer version decides and the newest generation wins.
* Without a package checksum nothing is provably the same lineage and nothing collapses.
*/
export function currentBaselineAnalyses(
records: readonly StoredMicrosoftSecurityBaselineAnalysis[]
): StoredMicrosoftSecurityBaselineAnalysis[] {
const superseded = new Set(records.map(record => record.supersedesAnalysisId).filter(Boolean));
const lineage = (record: StoredMicrosoftSecurityBaselineAnalysis) => record.analysis.packageSha256
? `${record.analysis.baselineId}${record.analysis.packageSha256}` : `${record.analysis.id}`;
const current = new Map<string, StoredMicrosoftSecurityBaselineAnalysis>();
for (const record of records.filter(record => !superseded.has(record.analysis.id))
.sort((left, right) => compareAnalyzerVersions(right.analysis.analyzer?.version ?? '', left.analysis.analyzer?.version ?? '')
|| (right.analysis.analyzedAt ?? '').localeCompare(left.analysis.analyzedAt ?? '')
|| left.analysis.id.localeCompare(right.analysis.id))) {
if (!current.has(lineage(record))) current.set(lineage(record), record);
}
return [...current.values()];
}
/** One current analysis per baseline for consumers that select a single benchmark. */
function currentBaselineAnalysesByBaseline(
records: readonly StoredMicrosoftSecurityBaselineAnalysis[]
): Map<string, StoredMicrosoftSecurityBaselineAnalysis> {
const byBaseline = new Map<string, StoredMicrosoftSecurityBaselineAnalysis>();
for (const record of currentBaselineAnalyses(records)) {
if (!byBaseline.has(record.analysis.baselineId)) byBaseline.set(record.analysis.baselineId, record);
}
return byBaseline;
}
/**
* Moves every retained baseline reference to its current analysis generation. The
* evaluated baseline set is never widened: an unknown reference and a baseline
* without a newer generation keep their own analysis id.
*/
function currentBaselineAnalysisIds(
retained: readonly string[],
records: readonly StoredMicrosoftSecurityBaselineAnalysis[]
): string[] {
const byId = new Map(records.map(record => [record.analysis.id, record]));
const currentByLineage = new Map(currentBaselineAnalyses(records).map(record =>
[`${record.analysis.baselineId}${record.analysis.packageSha256 ?? record.analysis.id}`, record]));
return uniqueSorted(retained.map(id => {
const record = byId.get(id);
if (!record) return id;
const key = `${record.analysis.baselineId}${record.analysis.packageSha256 ?? record.analysis.id}`;
return currentByLineage.get(key)?.analysis.id ?? id;
}));
}
/** Readiness follows active profile/snapshot provenance, never a finding title or position. */
export function gpoEvaluationUpdates(input: Pick<BuildPolicyAnalysisOverviewInput,
'identityAnalyses' | 'profiles' | 'aggregations' | 'evaluations' | 'baselineAnalyses'>): GpoEvaluationUpdate[] {
const active = activePolicyRecords({ ...input, findingRuns: [] });
const analyses = new Map(active.identityAnalyses.map(record => [record.run.id, record.run.artifactId]));
return active.aggregations.flatMap(aggregation => {
const previous = active.evaluations.find(record => record.run.aggregationRunId === aggregation.run.id);
if (!previous) return [];
// A newer baseline analysis generation changes the comparison inputs of the same
// aggregation even when the evaluation engine version is unchanged.
const baselineAnalysisIds = currentBaselineAnalysisIds(previous.run.baselineAnalysisIds, input.baselineAnalyses ?? []);
const evaluations = input.evaluations.filter(record => record.run.aggregationRunId === aggregation.run.id);
if (evaluations.some(record => record.run.evaluator.id === POLICY_EVALUATION_ENGINE.id
&& record.run.evaluator.version === POLICY_EVALUATION_ENGINE.version
&& sameIds(record.run.baselineAnalysisIds, baselineAnalysisIds))) return [];
const reasons: GpoEvaluationUpdateReason[] = [];
if (previous.run.evaluator.version !== POLICY_EVALUATION_ENGINE.version) reasons.push('engine-version');
if (!sameIds(previous.run.baselineAnalysisIds, baselineAnalysisIds)) reasons.push('baseline-analysis');
return [{ evaluationRunId: previous.run.id, aggregationRunId: aggregation.run.id,
baselineAnalysisIds,
artifactIds: [...new Set(aggregation.run.sourceAnalysisRunIds.flatMap(id => analyses.has(id) ? [analyses.get(id)!] : []))],
previousVersion: previous.run.evaluator.version, currentVersion: POLICY_EVALUATION_ENGINE.version,
reasons }];
});
}
function summarizePolicyRecords(
identityAnalyses: StoredGpoPolicyAnalysis[],
aggregations: StoredPolicySnapshotAggregation[],
@ -327,18 +432,7 @@ function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisAc
} as PolicyAnalysisAggregationAction;
})).sort((left, right) => left.id.localeCompare(right.id));
const supersededBaselineIds = new Set((input.baselineAnalyses ?? [])
.map(record => record.supersedesAnalysisId).filter(Boolean));
const activeBaselineAnalyses = (input.baselineAnalyses ?? [])
.filter(record => !supersededBaselineIds.has(record.analysis.id))
.sort((left, right) => right.analysis.analyzedAt.localeCompare(left.analysis.analyzedAt)
|| left.analysis.id.localeCompare(right.analysis.id));
const baselineAnalysisByBaseline = new Map<string, StoredMicrosoftSecurityBaselineAnalysis>();
for (const record of activeBaselineAnalyses) {
if (!baselineAnalysisByBaseline.has(record.analysis.baselineId)) {
baselineAnalysisByBaseline.set(record.analysis.baselineId, record);
}
}
const baselineAnalysisByBaseline = currentBaselineAnalysesByBaseline(input.baselineAnalyses ?? []);
const profilesById = new Map(input.profiles.map(profile => [profile.id, profile]));
const evaluate: PolicyAnalysisEvaluationAction[] = active.aggregations.map(aggregation => {
const profile = profilesById.get(aggregation.run.environmentProfileId);
@ -408,6 +502,12 @@ function buildActions(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisAc
export function buildPolicyAnalysisOverview(input: BuildPolicyAnalysisOverviewInput): PolicyAnalysisOverview {
const reviews: PolicyAnalysisReviewItem[] = [];
// The aggregation restates every unresolved identity of its source analyses. Reporting the
// same observation twice would inflate the gap count and hide the authoritative reason.
const reportedUnresolved = new Set(input.identityAnalyses.flatMap(analysis => analysis.resolutions
.flatMap(item => item.resolution.status === 'unresolved'
? [`${item.resolution.reasonCode}${item.observationId}`] : [])));
for (const analysis of input.identityAnalyses) {
if (analysis.run.status === 'partial') {
reviews.push(reviewItem({
@ -448,6 +548,8 @@ export function buildPolicyAnalysisOverview(input: BuildPolicyAnalysisOverviewIn
}));
}
for (const hint of aggregation.reviewHints) {
if (hint.observationIds.length > 0
&& hint.observationIds.every(id => reportedUnresolved.has(`${hint.code}${id}`))) continue;
reviews.push(reviewItem({
source: 'snapshot-aggregation', sourceRunId: aggregation.run.id, subjectId: hint.id,
identityId: hint.identityId, codes: [hint.code],

View File

@ -3,6 +3,19 @@ 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.baselineVariantAssessment) {
if (result.baselineVariantAssessment.decision !== 'all-fail'
|| result.classification !== 'confirmed_configured_deviation' || relation !== 'different') return undefined;
const missingRequiredPresence = result.customerState === 'missing'
&& result.baselineVariantAssessment.variants.length > 0
&& result.baselineVariantAssessment.variants.every(variant => {
const expected = variant.comparator.baseline;
return (expected?.kind === 'registry-state' && expected.state === 'key-present')
|| (expected?.kind === 'text' && expected.value === '');
});
return result.customerState === 'missing' ? missingRequiredPresence ? 'misconfigured' : 'not-configured'
: ['configured', 'conflicted'].includes(result.customerState) ? 'misconfigured' : undefined;
}
if (result.baselineState !== 'explicit-recommendation' || !baseline || baseline.kind === 'none'
|| (baseline.kind === 'text' && !baseline.value.trim())
|| result.diagnosticCodes.includes('policy-evaluation-baseline-consensus-conflict')

View File

@ -6,7 +6,7 @@ import {
type PolicyEvaluationRuleCatalog
} from './policy-evaluation-rules';
export const POLICY_EVALUATION_ENGINE = { id: 'policy-baseline-evaluation', version: '1.4.0' } as const;
export const POLICY_EVALUATION_ENGINE = { id: 'policy-baseline-evaluation', version: '1.9.0' } 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;
@ -17,6 +17,7 @@ export type PolicyComparableValue =
| { kind: 'integer'; value: string }
| { kind: 'binary'; hexadecimal: string }
| { kind: 'string-set'; values: string[] }
| { kind: 'registry-state'; state: 'absent' | 'key-empty' | 'key-present' }
| { kind: 'audit-flags'; success: boolean; failure: boolean };
export type PolicyComparatorDefinition =
@ -39,6 +40,31 @@ export interface PolicyComparatorOutcome {
diagnosticCodes: string[];
}
/** Independent comparisons, never a union or a conveniently selected recommendation. */
export interface PolicyBaselineVariantAssessment {
version: 1;
scope: 'backup-configuration';
decision: 'all-pass' | 'all-fail' | 'mixed' | 'unavailable';
actualValues: GpoPolicyValue[];
variants: Array<{
settingId: string;
baselineValues: GpoPolicyValue[];
comparator: PolicyComparatorOutcome;
}>;
/** Assessment blockers; an unresolved role alone does not prevent unanimity. */
diagnosticCodes: string[];
}
export function policyBaselineVariantDecision(
variants: PolicyBaselineVariantAssessment['variants'], blockers: readonly string[] = []
): PolicyBaselineVariantAssessment['decision'] {
if (blockers.length || !variants.length
|| variants.some(variant => !['match', 'different'].includes(variant.comparator.relation))) return 'unavailable';
if (variants.every(variant => variant.comparator.relation === 'match')) return 'all-pass';
if (variants.every(variant => variant.comparator.relation === 'different')) return 'all-fail';
return 'mixed';
}
export type PolicyEvaluationClassification =
| 'configured_match'
| 'confirmed_configured_deviation'
@ -64,6 +90,8 @@ export interface PolicyEvaluationResult {
id: string;
evaluationRunId: string;
aggregationStateId?: string;
/** Exact contributing states when a registry-key requirement depends on child value identities. */
aggregationStateIds?: string[];
baselineAnalysisId: string;
baselineId: string;
baselineLabel: string;
@ -71,6 +99,7 @@ export interface PolicyEvaluationResult {
identity: GpoTechnicalPolicyIdentity;
classification: PolicyEvaluationClassification;
comparator: PolicyComparatorOutcome;
baselineVariantAssessment?: PolicyBaselineVariantAssessment;
baselineState: PolicyBaselineState;
contextIds: string[];
ruleIds: string[];
@ -98,7 +127,7 @@ export interface PolicyEvaluationRun {
environmentProfileId: string;
baselineAnalysisIds: string[];
/** Previous results remain readable; validation of new writes still requires the current engine. */
evaluator: { id: typeof POLICY_EVALUATION_ENGINE.id; version: typeof POLICY_EVALUATION_ENGINE.version | '1.3.3' };
evaluator: { id: typeof POLICY_EVALUATION_ENGINE.id; version: typeof POLICY_EVALUATION_ENGINE.version | '1.8.0' | '1.7.0' | '1.6.0' | '1.5.0' | '1.4.0' | '1.3.3' };
comparatorCatalog: typeof POLICY_COMPARATOR_CATALOG;
dependencyRules: typeof POLICY_DEPENDENCY_RULESET;
riskRules: typeof POLICY_RISK_RULESET;
@ -163,6 +192,29 @@ export function storedPolicyEvaluationValidationErrors(record: StoredPolicyEvalu
|| Date.parse(run.completedAt) < Date.parse(run.startedAt)) errors.push('Die Laufzeit der Policy-Evaluation ist ungueltig.');
const resultIds = new Set<string>();
for (const result of results) {
const assessment = result.baselineVariantAssessment;
if (assessment) {
const ids = assessment.variants.map(variant => variant.settingId);
const expected = assessment.decision === 'all-pass' ? 'configured_match'
: assessment.decision === 'all-fail' ? 'confirmed_configured_deviation' : 'review_required';
const relation = assessment.decision === 'all-pass' ? 'match' : assessment.decision === 'all-fail' ? 'different' : 'incomparable';
const satisfiedByAbsence = result.customerState === 'missing' && assessment.decision === 'all-pass'
&& assessment.variants.every(variant => variant.comparator.actual?.kind === 'registry-state'
&& variant.comparator.actual.state === 'absent'
&& variant.comparator.baseline?.kind === 'registry-state'
&& ['absent', 'key-empty'].includes(variant.comparator.baseline.state));
if (assessment.version !== 1 || assessment.scope !== 'backup-configuration' || !uniqueSorted(ids)
|| ids.length !== result.baselineSettingIds.length || ids.some(id => !result.baselineSettingIds.includes(id))
|| assessment.decision !== policyBaselineVariantDecision(assessment.variants, assessment.diagnosticCodes)
|| result.classification !== expected || result.comparator.relation !== relation
|| (assessment.decision === 'all-pass' && !['configured', 'conflicted'].includes(result.customerState)
&& !satisfiedByAbsence)
|| (result.customerState === 'indeterminate' && assessment.decision !== 'unavailable')
|| (result.baselineTemplateSelection?.status === 'no-match' && assessment.decision !== 'unavailable')
|| assessment.variants.some(variant => variant.comparator.comparator.kind !== 'equality')) {
errors.push('Die Baselinevarianten besitzen inkonsistente Einzelurteile oder Referenzen.');
}
}
const selection = result.baselineTemplateSelection;
if (selection && (!['selected', 'unresolved', 'no-match'].includes(selection.status)
|| !uniqueSorted(selection.candidateSettingIds) || !uniqueSorted(selection.contextIds)
@ -172,6 +224,7 @@ export function storedPolicyEvaluationValidationErrors(record: StoredPolicyEvalu
}
if (result.version !== 1 || !validText(result.id) || resultIds.has(result.id) || result.evaluationRunId !== run.id
|| (result.aggregationStateId !== undefined && !validText(result.aggregationStateId))
|| (result.aggregationStateIds !== undefined && !uniqueSorted(result.aggregationStateIds))
|| !validText(result.baselineAnalysisId) || !run.baselineAnalysisIds.includes(result.baselineAnalysisId)
|| !validText(result.baselineId) || !validText(result.baselineLabel)
|| !['configured', 'missing', 'conflicted', 'indeterminate'].includes(result.customerState)
@ -193,8 +246,9 @@ export function storedPolicyEvaluationValidationErrors(record: StoredPolicyEvalu
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)) {
|| (result.customerState === 'missing' && (result.aggregationStateId !== undefined
|| result.aggregationStateIds?.length || result.observationIds.length > 0))
|| (result.customerState !== 'missing' && !result.aggregationStateId && !result.aggregationStateIds?.length)) {
errors.push('Ein Policy-Evaluation-Ergebnis besitzt eine inkonsistente Ist- oder Baseline-Referenz.');
}
resultIds.add(result.id);

View File

@ -0,0 +1,20 @@
/** Shared user-facing vocabulary; stored verdicts and query values stay unchanged. */
export const POLICY_STATUS_LABELS = {
deviation: 'Deviation',
passed: 'Passed',
'not-configured': 'Not configured',
'no-baseline-rule': 'No baseline rule',
'not-comparable': 'Not comparable',
conflicts: 'Conflicts',
duplicates: 'Duplicates',
'review-required': 'Review required'
} as const;
export const POLICY_CLASSIFICATION_LABELS = {
misconfigured: POLICY_STATUS_LABELS.deviation,
match: POLICY_STATUS_LABELS.passed,
'not-configured': POLICY_STATUS_LABELS['not-configured'],
conflict: POLICY_STATUS_LABELS.conflicts,
duplicate: POLICY_STATUS_LABELS.duplicates,
'review-required': POLICY_STATUS_LABELS['review-required']
} as const;

View File

@ -0,0 +1,655 @@
import type { CanonicalOutcome, CanonicalSeverity } from './canonical-model';
import type { RemediationEvidenceState } from './timeslot-comparison';
import type { ToolPriorityBasisDefinition } from './tool-profile-definition';
import type { UnifiedResultProjection } from './unified-result';
import { severityRank } from './severity-order';
/**
* Shared projection behind every assessment output (docs/REPORT_STRATEGY.md):
* the Management Overview, the Technical Report, the Arbeitsgrundlage and the
* Delta Report. The renderers choose depth, never content, so a number shown in
* two artifacts is provably the same number.
*
* The projection is language-neutral. It carries codes, counts and identities;
* every German wording lives in a renderer. It is also platform-neutral: no
* clock, no storage, no network and no presentation types.
*/
export const REPORT_PROJECTION_VERSION = '1.0.0';
/* -------------------------------------------------------------------------- */
/* Priority basis */
/* -------------------------------------------------------------------------- */
/**
* Severity is not a property every scanner delivers: CIS-CAT and the GPO
* baseline comparison have none at all, and PingCastle cannot reach `critical`
* by construction. A source therefore declares how its findings may be ordered,
* or declares that it has no usable basis. Never infer one from an arbitrary
* field named severity, level, score or rank.
*/
export type ReportPriorityBasis =
| { kind: 'severity' }
| {
kind: 'numeric';
/** Exact mapped property behind `UnifiedResultProjection.score`. */
property: string;
scale: string;
direction: 'higher-is-worse' | 'lower-is-worse';
}
| { kind: 'equal-weight'; reason: string }
| { kind: 'undeclared' };
export type ReportPriorityBasisIssue =
| 'basis-not-declared'
| 'declared-severity-without-coverage'
| 'declared-numeric-without-values';
/**
* Reads a profile's declaration. An `unavailable` declaration is a decision, not
* a gap: it becomes an explicit `equal-weight` basis carrying the profile's own
* reason, so a report can say why a source is unranked instead of implying the
* declaration is missing.
*/
export function reportPriorityBasisFrom(definition: ToolPriorityBasisDefinition): ReportPriorityBasis {
const { basis } = definition;
if (basis.kind === 'severity') return { kind: 'severity' };
if (basis.kind === 'numeric') {
return {
kind: 'numeric',
property: basis.properties[0],
scale: basis.scale,
direction: basis.direction
};
}
return { kind: 'equal-weight', reason: basis.reason };
}
/** Declarations of every registered profile, keyed by tool id. */
export function reportPriorityBases(
definitions: ReadonlyArray<{ id: string; priorityBasis: ToolPriorityBasisDefinition }>
): Record<string, ReportPriorityBasis> {
return Object.fromEntries(definitions.map(definition =>
[definition.id, reportPriorityBasisFrom(definition.priorityBasis)]));
}
export interface ReportSourcePriority {
toolId: string;
declared: ReportPriorityBasis;
/** What the projection actually applied after validating the declaration. */
applied: ReportPriorityBasis;
/** Share of this source's deviations carrying a usable severity, 0..1. */
severityCoverage: number;
issues: ReportPriorityBasisIssue[];
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
/** One row of the shared Client Info security-domain catalog. */
export interface ReportDomain {
id: string;
label: string;
}
/** A single environment fact. An absent value stays absent, never zero. */
export interface ReportFact {
key: string;
value?: string;
}
export interface ReportScoreSource {
toolId: string;
/** Versioned ASSESSOR contribution on the 0-100 scale, absent when unavailable. */
score?: number;
method: 'native' | 'inverted-native' | 'pass-rate' | 'unavailable';
weight: number;
nativeValue?: number;
nativeProperty?: string;
passed: number;
deviations: number;
assessed: number;
observedAt?: string;
issues: string[];
}
/**
* One rolled-up GPO policy setting. Supplied by the caller from the evaluation
* layer so that the projection never re-derives a verdict of its own.
*/
export interface ReportPolicyEntry {
id: string;
/** Authoritative rolled-up verdict; diagnostics stay separate. */
verdict: 'Fail' | 'Pass' | 'Not configured' | 'No baseline rule' | 'Not comparable' | 'Review required';
conflict: boolean;
duplicate: boolean;
title: string;
scope: string;
identity: string;
backupInstanceIds: string[];
}
export interface ReportDiagnostic {
code: string;
/** Where the diagnostic was raised; keeps one observation from being restated. */
origin: 'import' | 'aggregation' | 'evaluation' | 'comparison' | 'projection';
count: number;
}
export interface ReportProvenanceEntry {
kind: string;
id: string;
version?: string;
observedAt?: string;
}
/** Client-owned planning fields. Never proof, always self-reported. */
export interface ReportReviewInput {
resultProjectionId: string;
include: boolean;
status: string;
priority: string;
owner?: string;
due?: string;
prerequisites?: string;
access?: string;
nextStep?: string;
markers: string[];
riskAccepted: boolean;
}
/**
* A stored timeslot comparison. The reference run is chosen in ASSESSOR; the
* projection reads that choice and never derives a pairing of its own.
*/
export interface ReportDeltaInput {
comparisonId: string;
contractVersion: string;
referenceRunId: string;
referenceObservedAt?: string;
currentRunId: string;
currentObservedAt?: string;
entries: Array<{
resultProjectionId: string;
toolId?: string;
state: RemediationEvidenceState;
reasons: string[];
}>;
}
export interface ReportProjectionInput {
generatedAt: string;
project: { id: string; name: string };
assessmentId?: string;
results: UnifiedResultProjection[];
domains: ReadonlyArray<ReportDomain>;
/** Shared catalog assignment, injected so the domain layer stays independent. */
domainOf: (result: UnifiedResultProjection) => string;
priorityBases?: Readonly<Record<string, ReportPriorityBasis>>;
scoreSources?: ReportScoreSource[];
overallScore?: number;
environment?: ReportFact[];
policyEntries?: ReportPolicyEntry[];
policyScopeLimited?: boolean;
reviews?: ReportReviewInput[];
diagnostics?: ReportDiagnostic[];
provenance?: ReportProvenanceEntry[];
delta?: ReportDeltaInput;
/** Cap per domain block in the management cut. Zero means no cap. */
priorityLimitPerDomain?: number;
}
/* -------------------------------------------------------------------------- */
/* Output */
/* -------------------------------------------------------------------------- */
export interface ReportFindingRef {
resultProjectionId: string;
sourceRuleId?: string;
toolId?: string;
domainId: string;
title: string;
affectedAsset?: string;
severity: CanonicalSeverity;
outcome: CanonicalOutcome;
/** The source's own rank value, meaningless across sources. */
rankValue?: number;
rankBasis: ReportPriorityBasis;
ranked: boolean;
}
export interface ReportDomainPriority {
domainId: string;
domainLabel: string;
deviations: number;
/** Grouped per source; ordering never crosses sources. */
sources: Array<{
toolId: string;
basis: ReportPriorityBasis;
ranked: boolean;
shown: ReportFindingRef[];
total: number;
}>;
}
export interface ReportDomainSummary {
domainId: string;
domainLabel: string;
passed: number;
deviations: number;
assessed: number;
/** Pass rate 0..1, absent when nothing comparable was assessed. */
passRate?: number;
}
export interface ReportGpoBlock {
/** GPO deviations carry no severity and are never ranked against each other. */
basis: Extract<ReportPriorityBasis, { kind: 'equal-weight' }>;
counts: Record<ReportPolicyEntry['verdict'], number>;
conflicts: number;
duplicates: number;
affectedSettings: number;
affectedBackups: number;
scopeLimited: boolean;
}
export interface ReportCoverage {
total: number;
assessed: number;
deviations: number;
passed: number;
notRun: number;
notApplicable: number;
executionErrors: number;
unknown: number;
/** Sources present in the results, with how much of each could be assessed. */
perSource: Array<{ toolId: string; total: number; assessed: number; notRun: number }>;
}
export interface ReportInventory {
bySeverity: Record<CanonicalSeverity, number>;
byOutcome: Record<CanonicalOutcome, number>;
byDomain: Record<string, number>;
bySource: Record<string, number>;
}
export interface ReportRemediationSummary {
/** Self-reported planning state. Never a resolved finding. */
selfReported: true;
items: number;
byStatus: Record<string, number>;
withoutOwner: number;
withoutDue: number;
riskAccepted: number;
}
export interface ReportWorkItem {
resultProjectionId: string;
domainId: string;
toolId?: string;
title: string;
affectedAsset?: string;
severity: CanonicalSeverity;
/** Planning fields, marked at the point of display as unverified. */
status: string;
priority: string;
owner?: string;
due?: string;
prerequisites?: string;
access?: string;
nextStep?: string;
markers: string[];
riskAccepted: boolean;
}
export interface ReportDelta {
comparisonId: string;
contractVersion: string;
referenceRunId: string;
referenceObservedAt?: string;
currentRunId: string;
currentObservedAt?: string;
counts: Record<RemediationEvidenceState, number>;
/** Per source, so a format that cannot prove absence is visible as such. */
perSource: Array<{ toolId: string; counts: Record<RemediationEvidenceState, number> }>;
entries: Array<{
resultProjectionId: string;
toolId?: string;
state: RemediationEvidenceState;
reasons: string[];
title?: string;
domainId?: string;
}>;
}
export interface ReportProjection {
version: typeof REPORT_PROJECTION_VERSION;
generatedAt: string;
project: { id: string; name: string };
assessmentId?: string;
environment: ReportFact[];
overallScore?: number;
scoreSources: ReportScoreSource[];
domains: ReportDomainSummary[];
priorities: ReportDomainPriority[];
gpo?: ReportGpoBlock;
coverage: ReportCoverage;
inventory: ReportInventory;
/** Present only when reviews exist; an empty section is omitted, not rendered. */
remediation?: ReportRemediationSummary;
/**
* Every deviation, unordered. Report B groups it, artifact C uses it for the
* complete second sheet. Ordering lives in `priorities`, never here.
*/
findings: ReportFindingRef[];
policyEntries: ReportPolicyEntry[];
workItems: ReportWorkItem[];
sourcePriorities: ReportSourcePriority[];
diagnostics: ReportDiagnostic[];
provenance: ReportProvenanceEntry[];
delta?: ReportDelta;
}
/* -------------------------------------------------------------------------- */
/* Build */
/* -------------------------------------------------------------------------- */
const SEVERITIES: CanonicalSeverity[] = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
const OUTCOMES: CanonicalOutcome[] = ['fail', 'pass', 'warning', 'informational',
'execution_error', 'not_run', 'not_applicable', 'not_evaluated', 'unknown'];
const REMEDIATION_STATES: RemediationEvidenceState[] = ['confirmed-open', 'verified-remediated',
'unverified-absent', 'not-applicable', 'not-comparable'];
const GPO_EQUAL_WEIGHT = {
kind: 'equal-weight',
reason: 'A baseline deviation carries no native criticality.'
} as const;
function emptyCounts<K extends string>(keys: readonly K[]): Record<K, number> {
return Object.fromEntries(keys.map(key => [key, 0])) as Record<K, number>;
}
function toolOf(result: UnifiedResultProjection): string {
return result.sourceTool ?? 'unknown';
}
/**
* Validates a declared basis against the data it would order. A source that
* declares severity but delivers none must not silently produce an ordering
* that only describes its report format.
*/
function resolveSourcePriority(
toolId: string, declared: ReportPriorityBasis, deviations: UnifiedResultProjection[]
): ReportSourcePriority {
const withSeverity = deviations.filter(item => item.severityNormalized !== 'unknown').length;
const severityCoverage = deviations.length ? withSeverity / deviations.length : 0;
const issues: ReportPriorityBasisIssue[] = [];
let applied = declared;
if (declared.kind === 'undeclared') {
issues.push('basis-not-declared');
} else if (declared.kind === 'severity' && withSeverity === 0) {
issues.push('declared-severity-without-coverage');
applied = { kind: 'undeclared' };
} else if (declared.kind === 'numeric'
&& !deviations.some(item => typeof item.score === 'number' && Number.isFinite(item.score))) {
issues.push('declared-numeric-without-values');
applied = { kind: 'undeclared' };
}
return { toolId, declared, applied, severityCoverage, issues };
}
function rankValueFor(result: UnifiedResultProjection, basis: ReportPriorityBasis): number | undefined {
if (basis.kind === 'severity') return severityRank(result.severityNormalized);
if (basis.kind === 'numeric') {
return typeof result.score === 'number' && Number.isFinite(result.score) ? result.score : undefined;
}
return undefined;
}
/**
* Ordering is defined inside one source only; there is no cross-source rank.
* `severityRank` counts upwards from `critical`, so severity sorts ascending
* while a higher-is-worse numeric basis sorts descending.
*/
function orderWithinSource(items: ReportFindingRef[], basis: ReportPriorityBasis): ReportFindingRef[] {
if (basis.kind !== 'severity' && basis.kind !== 'numeric') return items;
const worseFirst = basis.kind === 'numeric' && basis.direction === 'higher-is-worse';
return [...items].sort((left, right) => {
const a = left.rankValue;
const b = right.rankValue;
if (a === undefined && b === undefined) return 0;
if (a === undefined) return 1;
if (b === undefined) return -1;
return worseFirst ? b - a : a - b;
});
}
export function buildReportProjection(input: ReportProjectionInput): ReportProjection {
const { results, domainOf, domains } = input;
const bases = input.priorityBases ?? {};
const deviations = results.filter(result => result.isDeviation);
const deviationsByTool = new Map<string, UnifiedResultProjection[]>();
for (const result of deviations) {
const tool = toolOf(result);
const bucket = deviationsByTool.get(tool);
if (bucket) bucket.push(result); else deviationsByTool.set(tool, [result]);
}
const sourcePriorities = [...deviationsByTool].map(([toolId, items]) =>
resolveSourcePriority(toolId, bases[toolId] ?? { kind: 'undeclared' }, items));
const appliedByTool = new Map(sourcePriorities.map(entry => [entry.toolId, entry.applied]));
const refOf = (result: UnifiedResultProjection): ReportFindingRef => {
const tool = toolOf(result);
const basis = appliedByTool.get(tool) ?? { kind: 'undeclared' as const };
return {
resultProjectionId: result.resultProjectionId,
sourceRuleId: result.sourceRuleId,
toolId: result.sourceTool,
domainId: domainOf(result),
title: result.title,
affectedAsset: result.affectedAsset,
severity: result.severityNormalized,
outcome: result.outcomeNormalized,
rankValue: rankValueFor(result, basis),
rankBasis: basis,
ranked: basis.kind === 'severity' || basis.kind === 'numeric'
};
};
const deviationRefs = deviations.map(refOf);
// Priority Findings: grouped by domain, ordered per source, never across sources.
const limit = input.priorityLimitPerDomain ?? 0;
const priorities: ReportDomainPriority[] = domains.map(domain => {
const inDomain = deviationRefs.filter(ref => ref.domainId === domain.id);
const byTool = new Map<string, ReportFindingRef[]>();
for (const ref of inDomain) {
const tool = ref.toolId ?? 'unknown';
const bucket = byTool.get(tool);
if (bucket) bucket.push(ref); else byTool.set(tool, [ref]);
}
return {
domainId: domain.id,
domainLabel: domain.label,
deviations: inDomain.length,
sources: [...byTool].map(([toolId, items]) => {
const basis = appliedByTool.get(toolId) ?? { kind: 'undeclared' as const };
const ordered = orderWithinSource(items, basis);
return {
toolId, basis,
ranked: basis.kind === 'severity' || basis.kind === 'numeric',
shown: limit > 0 ? ordered.slice(0, limit) : ordered,
total: items.length
};
})
};
}).filter(entry => entry.deviations > 0);
const domainSummaries: ReportDomainSummary[] = domains.map(domain => {
const inDomain = results.filter(result => domainOf(result) === domain.id);
const passed = inDomain.filter(result => result.outcomeNormalized === 'pass').length;
const failed = inDomain.filter(result => result.isDeviation).length;
const assessed = passed + failed;
return {
domainId: domain.id,
domainLabel: domain.label,
passed,
deviations: failed,
assessed,
passRate: assessed ? passed / assessed : undefined
};
});
const coverage: ReportCoverage = {
total: results.length,
assessed: results.filter(result => result.outcomeNormalized === 'pass' || result.isDeviation).length,
deviations: deviations.length,
passed: results.filter(result => result.outcomeNormalized === 'pass').length,
notRun: results.filter(result => result.outcomeNormalized === 'not_run').length,
notApplicable: results.filter(result => result.outcomeNormalized === 'not_applicable').length,
executionErrors: results.filter(result => result.outcomeNormalized === 'execution_error').length,
unknown: results.filter(result => result.outcomeNormalized === 'unknown').length,
perSource: [...new Set(results.map(toolOf))].sort().map(toolId => {
const items = results.filter(result => toolOf(result) === toolId);
return {
toolId,
total: items.length,
assessed: items.filter(item => item.outcomeNormalized === 'pass' || item.isDeviation).length,
notRun: items.filter(item => item.outcomeNormalized === 'not_run').length
};
})
};
const inventory: ReportInventory = {
bySeverity: emptyCounts(SEVERITIES),
byOutcome: emptyCounts(OUTCOMES),
byDomain: {},
bySource: {}
};
for (const result of results) {
inventory.bySeverity[result.severityNormalized] += 1;
inventory.byOutcome[result.outcomeNormalized] += 1;
const domainId = domainOf(result);
inventory.byDomain[domainId] = (inventory.byDomain[domainId] ?? 0) + 1;
const tool = toolOf(result);
inventory.bySource[tool] = (inventory.bySource[tool] ?? 0) + 1;
}
const policyEntries = input.policyEntries ?? [];
const gpo: ReportGpoBlock | undefined = policyEntries.length ? {
basis: GPO_EQUAL_WEIGHT,
counts: policyEntries.reduce((counts, entry) => {
counts[entry.verdict] += 1;
return counts;
}, emptyCounts<ReportPolicyEntry['verdict']>(
['Fail', 'Pass', 'Not configured', 'No baseline rule', 'Not comparable', 'Review required'])),
conflicts: policyEntries.filter(entry => entry.conflict).length,
duplicates: policyEntries.filter(entry => entry.duplicate).length,
affectedSettings: new Set(policyEntries.filter(entry => entry.verdict === 'Fail')
.map(entry => `${entry.scope}${entry.identity}`)).size,
affectedBackups: new Set(policyEntries.filter(entry => entry.verdict === 'Fail')
.flatMap(entry => entry.backupInstanceIds)).size,
scopeLimited: input.policyScopeLimited ?? false
} : undefined;
const reviews = input.reviews ?? [];
const reviewById = new Map(reviews.map(review => [review.resultProjectionId, review]));
const remediation: ReportRemediationSummary | undefined = reviews.length ? {
selfReported: true,
items: reviews.filter(review => review.include).length,
byStatus: reviews.filter(review => review.include).reduce<Record<string, number>>((counts, review) => {
counts[review.status] = (counts[review.status] ?? 0) + 1;
return counts;
}, {}),
withoutOwner: reviews.filter(review => review.include && !review.owner).length,
withoutDue: reviews.filter(review => review.include && !review.due).length,
riskAccepted: reviews.filter(review => review.riskAccepted).length
} : undefined;
// Artifact C sheet 1: only what the consultant selected through `include`.
const workItems: ReportWorkItem[] = results.flatMap(result => {
const review = reviewById.get(result.resultProjectionId);
if (!review?.include) return [];
return [{
resultProjectionId: result.resultProjectionId,
domainId: domainOf(result),
toolId: result.sourceTool,
title: result.title,
affectedAsset: result.affectedAsset,
severity: result.severityNormalized,
status: review.status,
priority: review.priority,
owner: review.owner,
due: review.due,
prerequisites: review.prerequisites,
access: review.access,
nextStep: review.nextStep,
markers: review.markers,
riskAccepted: review.riskAccepted
}];
});
const delta: ReportDelta | undefined = input.delta ? {
comparisonId: input.delta.comparisonId,
contractVersion: input.delta.contractVersion,
referenceRunId: input.delta.referenceRunId,
referenceObservedAt: input.delta.referenceObservedAt,
currentRunId: input.delta.currentRunId,
currentObservedAt: input.delta.currentObservedAt,
counts: input.delta.entries.reduce((counts, entry) => {
counts[entry.state] += 1;
return counts;
}, emptyCounts(REMEDIATION_STATES)),
perSource: [...new Set(input.delta.entries.map(entry => entry.toolId ?? 'unknown'))].sort()
.map(toolId => ({
toolId,
counts: input.delta!.entries.filter(entry => (entry.toolId ?? 'unknown') === toolId)
.reduce((counts, entry) => {
counts[entry.state] += 1;
return counts;
}, emptyCounts(REMEDIATION_STATES))
})),
entries: input.delta.entries.map(entry => {
const result = results.find(item => item.resultProjectionId === entry.resultProjectionId);
return {
resultProjectionId: entry.resultProjectionId,
toolId: entry.toolId,
state: entry.state,
reasons: entry.reasons,
title: result?.title,
domainId: result ? domainOf(result) : undefined
};
})
} : undefined;
const diagnostics = [...(input.diagnostics ?? [])];
for (const entry of sourcePriorities) {
for (const issue of entry.issues) {
diagnostics.push({ code: `report-priority-${issue}:${entry.toolId}`, origin: 'projection', count: 1 });
}
}
return {
version: REPORT_PROJECTION_VERSION,
generatedAt: input.generatedAt,
project: input.project,
assessmentId: input.assessmentId,
environment: input.environment ?? [],
overallScore: input.overallScore,
scoreSources: input.scoreSources ?? [],
domains: domainSummaries,
priorities,
gpo,
coverage,
inventory,
remediation,
findings: deviationRefs,
policyEntries,
workItems,
sourcePriorities,
diagnostics,
provenance: input.provenance ?? [],
delta
};
}

View File

@ -1,4 +1,7 @@
import type { RiskAcceptance } from './risk-acceptance';
export interface Review {
riskAcceptance?: RiskAcceptance;
include: boolean;
status: string;
priority: string;
@ -17,6 +20,7 @@ export interface Review {
}
export const defaultReview = (): Review => ({
riskAcceptance: undefined,
include: false,
status: 'Open',
priority: 'Medium',

View File

@ -0,0 +1,136 @@
import type { FindingReview } from './canonical-model';
export type RiskAcceptanceAction = 'draft' | 'accept' | 'revoke';
export type RiskLevel = '' | 'Low' | 'Medium' | 'High' | 'Critical';
export interface RiskAcceptanceDetails {
scope: string;
scenario: string;
businessReason: string;
alternatives: string;
likelihood: string;
impact: string;
initialRisk: RiskLevel;
residualRisk: RiskLevel;
residualAssessment: string;
compensatingControls: string;
controlOwner: string;
controlEvidence: string;
riskOwner: string;
riskOwnerRole: string;
riskOwnerContact: string;
approver: string;
approverRole: string;
approverContact: string;
approvalReference: string;
approvedOn: string;
validFrom: string;
expiresOn: string;
reviewOn: string;
monitoring: string;
reassessmentTriggers: string;
exitPlan: string;
obligationsReview: string;
trackingReference: string;
evidenceReferences: string;
}
export interface RiskAcceptanceCommand {
action: RiskAcceptanceAction;
recordedBy: string;
changeReason: string;
details: RiskAcceptanceDetails;
}
export interface RiskAcceptanceRevision extends RiskAcceptanceCommand {
revision: number;
recordedAt: string;
targetOccurrenceId: string;
/** Names document an external decision; this local application does not authenticate approvers. */
approvalIdentityVerified: false;
}
export interface RiskAcceptance {
version: 1;
revisions: RiskAcceptanceRevision[];
}
export function emptyRiskAcceptanceDetails(): RiskAcceptanceDetails {
return { scope: '', scenario: '', businessReason: '', alternatives: '', likelihood: '', impact: '',
initialRisk: '', residualRisk: '', residualAssessment: '', compensatingControls: '', controlOwner: '', controlEvidence: '',
riskOwner: '', riskOwnerRole: '', riskOwnerContact: '', approver: '', approverRole: '', approverContact: '',
approvalReference: '', approvedOn: '', validFrom: '', expiresOn: '', reviewOn: '', monitoring: '',
reassessmentTriggers: '', exitPlan: '', obligationsReview: '', trackingReference: '', evidenceReferences: '' };
}
export class RiskAcceptanceValidationError extends Error {
constructor(public readonly issues: string[]) { super(issues.join(' ')); this.name = 'RiskAcceptanceValidationError'; }
}
const DATE_FIELDS = ['approvedOn', 'validFrom', 'expiresOn', 'reviewOn'] as const;
function validDate(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value))
&& new Date(value).toISOString().slice(0, 10) === value;
}
export function riskAcceptanceIssues(command: RiskAcceptanceCommand, today: string): string[] {
const issues: string[] = [];
if (!command || !['draft', 'accept', 'revoke'].includes(command.action)) return ['A valid decision action is required.'];
if (typeof command.recordedBy !== 'string' || !command.recordedBy.trim() || command.recordedBy.length > 500) issues.push('Recorded by is required (maximum 500 characters).');
if (typeof command.changeReason !== 'string' || !command.changeReason.trim() || command.changeReason.length > 4000) issues.push('A change / decision reason is required (maximum 4,000 characters).');
if (command.action === 'revoke') return issues;
const details = command.details;
if (!details || typeof details !== 'object' || Array.isArray(details)) return [...issues, 'Decision details are required.'];
for (const key of Object.keys(emptyRiskAcceptanceDetails()) as Array<keyof RiskAcceptanceDetails>) {
if (typeof details[key] !== 'string' || details[key].length > 12000) issues.push(`${key}: text is required, with at most 12,000 characters.`);
else if (command.action === 'accept' && !details[key].trim() && key !== 'evidenceReferences') issues.push(`${key}: complete this field before recording acceptance.`);
}
if (issues.length) return issues;
for (const field of DATE_FIELDS) if (details[field] && !validDate(details[field])) issues.push(`${field}: enter a valid date.`);
for (const field of ['initialRisk', 'residualRisk'] as const) if (!['', 'Low', 'Medium', 'High', 'Critical'].includes(details[field])) issues.push(`${field}: select a risk level.`);
if (command.action === 'accept') {
if (details.riskOwner.trim().toLowerCase() === details.approver.trim().toLowerCase()
|| details.riskOwnerContact.trim().toLowerCase() === details.approverContact.trim().toLowerCase()) issues.push('Risk owner and approving authority must be different people.');
if (details.approvedOn > today) issues.push('Approval cannot be dated in the future.');
if (details.approvedOn > details.validFrom) issues.push('Approval must be on or before the validity start.');
if (details.expiresOn <= today || details.expiresOn <= details.validFrom) issues.push('Expiry must be in the future and after the validity start.');
if (details.reviewOn <= today || details.reviewOn < details.validFrom || details.reviewOn > details.expiresOn) issues.push('Review must be in the future, within the validity period.');
if (details.validFrom < today) issues.push('A new acceptance cannot take effect retroactively.');
}
return issues;
}
export function riskAcceptanceState(value?: RiskAcceptance, occurrenceId?: string, today = new Date().toISOString().slice(0, 10)) {
const latest = value?.revisions.at(-1);
if (!latest) return 'Not recorded';
if (latest.action === 'revoke') return 'Revoked';
if (latest.action === 'draft') return 'Draft';
if (occurrenceId && latest.targetOccurrenceId !== occurrenceId) return 'Review required';
if (latest.details.expiresOn < today) return 'Expired';
if (latest.details.reviewOn <= today) return 'Review due';
if (latest.details.validFrom > today) return 'Scheduled';
return 'Accepted';
}
export function appendRiskAcceptanceReview(current: FindingReview | undefined, occurrenceId: string, input: RiskAcceptanceCommand, now: string): FindingReview {
const issues = riskAcceptanceIssues(input, now.slice(0, 10));
if (input?.action === 'revoke' && !current?.riskAcceptance?.revisions.some(revision => revision.action === 'accept')) issues.push('Only a previously accepted risk can be revoked.');
if (issues.length) throw new RiskAcceptanceValidationError(issues);
const previous = current?.riskAcceptance?.revisions ?? [];
const lastAccepted = [...previous].reverse().find(revision => revision.action === 'accept');
const details = input.action === 'revoke' ? structuredClone(lastAccepted!.details)
: Object.fromEntries(Object.keys(emptyRiskAcceptanceDetails()).map(key => [key, input.details[key as keyof RiskAcceptanceDetails].trim()])) as unknown as RiskAcceptanceDetails;
const revision: RiskAcceptanceRevision = {
action: input.action, recordedBy: input.recordedBy.trim(), changeReason: input.changeReason.trim(),
details,
revision: previous.length + 1, recordedAt: now, targetOccurrenceId: occurrenceId, approvalIdentityVerified: false
};
return {
...(current ?? { id: `review-occurrence-${occurrenceId}`, target: { kind: 'occurrence' as const, occurrenceId },
workflowStatus: 'open' as const, priority: 'medium' as const, includeInActionPlan: false, markers: [], createdAt: now }),
updatedAt: now, revision: (current?.revision ?? 0) + 1,
riskAcceptance: { version: 1, revisions: [...previous, revision] }
};
}
/** General review edits must not bypass the dedicated decision/history operation. */
export function assertRiskAcceptanceUnchanged(current: FindingReview | undefined, next: FindingReview): void {
if (current?.riskAcceptance && JSON.stringify(current.target) !== JSON.stringify(next.target)) throw new RiskAcceptanceValidationError(['A risk acceptance review cannot be retargeted.']);
if (JSON.stringify(current?.riskAcceptance) !== JSON.stringify(next.riskAcceptance)) {
throw new RiskAcceptanceValidationError(['Use the Accepted Risk dialog to change a risk acceptance.']);
}
}

View File

@ -0,0 +1,335 @@
import type { CanonicalOutcome, FindingOccurrence } from './canonical-model';
import { isDeviation } from './deviation-predicate';
import type { EnvironmentObservation } from './environment-context';
import type { ToolTimeslotComparisonDefinition } from './tool-profile-definition';
export const TIMESLOT_COMPARISON_CONTRACT_VERSION = '1.0.0' as const;
export type ComparisonGranularity = 'asset' | 'scope' | 'aggregate';
export interface ComparisonScopeIdentity {
kind: string;
key: string;
}
export type ComparisonScopeIdentityIssue =
| 'comparison-unavailable'
| 'scope-property-missing'
| 'scope-property-ambiguous';
export type ComparisonScopeIdentityResult =
| { state: 'verified'; scope: ComparisonScopeIdentity }
| { state: 'unverified'; reasons: ComparisonScopeIdentityIssue[]; properties: string[] };
export type ComparisonObservationTimeResult =
| { state: 'verified'; observedAt: string; property: string }
| { state: 'unverified'; reason: 'observation-time-missing' | 'observation-time-ambiguous' };
export interface ComparisonObjectIdentity {
kind: string;
namespace: string;
key: string;
}
export interface FindingComparisonIdentity {
version: typeof TIMESLOT_COMPARISON_CONTRACT_VERSION;
granularity: ComparisonGranularity;
toolId: string;
ruleId: string;
scope: ComparisonScopeIdentity;
object?: ComparisonObjectIdentity;
key: string;
}
export type FindingComparisonIdentityResult =
| { state: 'verified'; identity: FindingComparisonIdentity }
| { state: 'unverified'; reasons: ComparisonIdentityIssue[] };
export type ComparisonIdentityIssue =
| 'tool-id-missing'
| 'rule-id-missing'
| 'scope-kind-missing'
| 'scope-key-missing'
| 'object-kind-missing'
| 'object-namespace-missing'
| 'object-key-missing';
export interface FindingComparisonIdentityInput {
occurrence: FindingOccurrence;
granularity: ComparisonGranularity;
scope: ComparisonScopeIdentity;
/** Required only for asset-granular comparison; never inferred from a display title. */
object?: ComparisonObjectIdentity;
}
const normalized = (value: unknown): string => typeof value === 'string'
? value.trim().toLocaleLowerCase('en-US')
: '';
const normalizedIdentityValue = (value: unknown): string => {
if (typeof value === 'string') return normalized(value);
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return String(value);
return '';
};
/**
* Resolves a scanner scope only from profile-declared, observed properties.
* Duplicate equal values are harmless; missing or conflicting values fail
* closed. Labels, file names and import timestamps are never substitutes.
*/
export function comparisonScopeIdentity(
definition: ToolTimeslotComparisonDefinition,
observations: EnvironmentObservation[]
): ComparisonScopeIdentityResult {
if (definition.kind === 'unavailable') {
return { state: 'unverified', reasons: ['comparison-unavailable'], properties: [] };
}
const values: Array<[string, string]> = [];
const missing: string[] = [];
const ambiguous: string[] = [];
for (const property of definition.scope.properties) {
const candidates = [...new Set(observations
.filter(item => item.state === 'observed' && item.property === property)
.map(item => normalizedIdentityValue(item.normalizedValue ?? item.originalValue))
.filter(Boolean))];
if (candidates.length === 0) missing.push(property);
else if (candidates.length > 1) ambiguous.push(property);
else values.push([property, candidates[0]]);
}
if (missing.length || ambiguous.length) return {
state: 'unverified',
reasons: [
...(missing.length ? ['scope-property-missing' as const] : []),
...(ambiguous.length ? ['scope-property-ambiguous' as const] : [])
],
properties: [...missing, ...ambiguous]
};
return {
state: 'verified',
scope: {
kind: definition.scope.kind,
key: JSON.stringify([definition.version, definition.scope.kind, values])
}
};
}
/** Import time is intentionally excluded: reimporting the same report is not a
* newer security observation. */
export function comparisonObservationTime(
definition: ToolTimeslotComparisonDefinition,
observations: EnvironmentObservation[]
): ComparisonObservationTimeResult {
if (definition.kind === 'unavailable') return { state: 'unverified', reason: 'observation-time-missing' };
for (const property of definition.observationTimeProperties) {
const candidates = [...new Set(observations
.filter(item => item.state === 'observed' && item.property === property)
.map(item => item.normalizedValue ?? item.originalValue)
.filter((value): value is string => typeof value === 'string' && Number.isFinite(Date.parse(value)))
.map(value => new Date(value).toISOString()))];
if (candidates.length === 1) return { state: 'verified', observedAt: candidates[0], property };
if (candidates.length > 1) return { state: 'unverified', reason: 'observation-time-ambiguous' };
}
return { state: 'unverified', reason: 'observation-time-missing' };
}
/**
* Builds a comparison identity without changing the persisted rule definition.
* Callers must provide the profile-verified scope and object namespace.
*/
export function findingComparisonIdentity(input: FindingComparisonIdentityInput): FindingComparisonIdentityResult {
const toolId = normalized(input.occurrence.sourceIdentity.tool.id);
const ruleId = normalized(input.occurrence.sourceIdentity.canonicalRuleId
?? input.occurrence.sourceIdentity.originalRuleId);
const scope = { kind: normalized(input.scope.kind), key: normalized(input.scope.key) };
const object = input.object ? {
kind: normalized(input.object.kind),
namespace: normalized(input.object.namespace),
key: normalized(input.object.key)
} : undefined;
const reasons: ComparisonIdentityIssue[] = [];
if (!toolId) reasons.push('tool-id-missing');
if (!ruleId) reasons.push('rule-id-missing');
if (!scope.kind) reasons.push('scope-kind-missing');
if (!scope.key) reasons.push('scope-key-missing');
if (input.granularity === 'asset') {
if (!object?.kind) reasons.push('object-kind-missing');
if (!object?.namespace) reasons.push('object-namespace-missing');
if (!object?.key) reasons.push('object-key-missing');
}
if (reasons.length) return { state: 'unverified', reasons };
const identity: FindingComparisonIdentity = {
version: TIMESLOT_COMPARISON_CONTRACT_VERSION,
granularity: input.granularity,
toolId,
ruleId,
scope,
...(input.granularity === 'asset' ? { object: object! } : {}),
key: JSON.stringify([
TIMESLOT_COMPARISON_CONTRACT_VERSION,
input.granularity,
toolId,
ruleId,
scope.kind,
scope.key,
input.granularity === 'asset' ? object!.kind : null,
input.granularity === 'asset' ? object!.namespace : null,
input.granularity === 'asset' ? object!.key : null
])
};
return { state: 'verified', identity };
}
export type RemediationEvidenceState =
| 'confirmed-open'
| 'verified-remediated'
| 'unverified-absent'
| 'not-applicable'
| 'not-comparable';
export interface RemediationEvidenceDecision {
version: typeof TIMESLOT_COMPARISON_CONTRACT_VERSION;
state: RemediationEvidenceState;
identityKey?: string;
reasons: string[];
}
export interface RemediationEvidenceInput {
baselineIdentity: FindingComparisonIdentityResult;
targetIdentity?: FindingComparisonIdentityResult;
baselineObservedAt?: string;
targetObservedAt?: string;
processingCompatible: boolean;
targetOutcome?: CanonicalOutcome;
targetIsIoe?: boolean;
coverage: 'complete' | 'partial' | 'unknown';
/** Identities proven to have been within the successfully evaluated target population. */
coveredIdentityKeys?: string[];
/** A profile-declared complete result can prove a negative finding by absence. */
absenceSemantics: 'explicit-negative' | 'not-reported';
/** Only diagnostics whose impact reaches identity, this result, or its coverage. */
blockingDiagnostics?: string[];
}
function validNewerObservation(baseline?: string, target?: string): boolean {
return Boolean(baseline && target && Number.isFinite(Date.parse(baseline))
&& Number.isFinite(Date.parse(target)) && Date.parse(target!) > Date.parse(baseline!));
}
/**
* Decides remediation evidence independently of score comparability. Missing or
* ambiguous evidence remains unresolved; workflow status and risk acceptance do
* not enter this technical decision.
*/
export function evaluateRemediationEvidence(input: RemediationEvidenceInput): RemediationEvidenceDecision {
const version = TIMESLOT_COMPARISON_CONTRACT_VERSION;
if (input.baselineIdentity.state !== 'verified') {
return { version, state: 'not-comparable', reasons: input.baselineIdentity.reasons };
}
const identityKey = input.baselineIdentity.identity.key;
if (!input.processingCompatible) {
return { version, state: 'not-comparable', identityKey, reasons: ['processing-incompatible'] };
}
if (!validNewerObservation(input.baselineObservedAt, input.targetObservedAt)) {
return { version, state: 'unverified-absent', identityKey, reasons: ['newer-observation-not-verified'] };
}
if (input.blockingDiagnostics?.length) {
return { version, state: 'unverified-absent', identityKey, reasons: [...new Set(input.blockingDiagnostics)].sort() };
}
if (input.targetIdentity) {
if (input.targetIdentity.state !== 'verified') {
return { version, state: 'unverified-absent', identityKey, reasons: input.targetIdentity.reasons };
}
if (input.targetIdentity.identity.key !== identityKey) {
return { version, state: 'unverified-absent', identityKey, reasons: ['comparison-identity-changed'] };
}
if (input.targetOutcome === 'pass') {
return { version, state: 'verified-remediated', identityKey, reasons: ['same-identity-explicit-pass'] };
}
if (input.targetOutcome === 'not_applicable') {
return { version, state: 'not-applicable', identityKey, reasons: ['same-identity-not-applicable'] };
}
if (input.targetOutcome && isDeviation(Boolean(input.targetIsIoe), input.targetOutcome)) {
return { version, state: 'confirmed-open', identityKey, reasons: ['same-identity-current-deviation'] };
}
return { version, state: 'unverified-absent', identityKey, reasons: ['target-result-not-decisive'] };
}
const covered = new Set(input.coveredIdentityKeys ?? []).has(identityKey);
if (input.coverage === 'complete' && covered && input.absenceSemantics === 'explicit-negative') {
return { version, state: 'verified-remediated', identityKey, reasons: ['complete-profile-negative-evidence'] };
}
return {
version,
state: 'unverified-absent',
identityKey,
reasons: [
...(input.coverage !== 'complete' ? ['target-coverage-not-complete'] : []),
...(!covered ? ['identity-not-covered'] : []),
...(input.absenceSemantics !== 'explicit-negative' ? ['absence-not-explicit-negative-evidence'] : [])
]
};
}
export type FindingObligationState =
| 'open-confirmed'
| 'awaiting-revalidation'
| 'remediated'
| 'retired-out-of-scope';
export interface FindingObligation {
version: typeof TIMESLOT_COMPARISON_CONTRACT_VERSION;
id: string;
projectId: string;
identity: FindingComparisonIdentity;
originAssessmentId: string;
originRunId: string;
originOccurrenceId: string;
originObservedAt: string;
lastObservedAt: string;
state: FindingObligationState;
stateReason: string;
resolvedByOccurrenceId?: string;
retirement?: { actor: string; reason: string; effectiveAt: string };
}
export function advanceFindingObligation(
obligation: FindingObligation,
decision: RemediationEvidenceDecision,
targetOccurrenceId?: string,
targetObservedAt?: string
): FindingObligation {
if (decision.identityKey && decision.identityKey !== obligation.identity.key) return obligation;
const observedAt = targetObservedAt && Number.isFinite(Date.parse(targetObservedAt))
&& Date.parse(targetObservedAt) > Date.parse(obligation.lastObservedAt)
? targetObservedAt : obligation.lastObservedAt;
if (decision.state === 'confirmed-open') return {
...obligation, state: 'open-confirmed', stateReason: decision.reasons.join('; '),
lastObservedAt: observedAt, resolvedByOccurrenceId: undefined, retirement: undefined
};
if (decision.state === 'verified-remediated') return {
...obligation, state: 'remediated', stateReason: decision.reasons.join('; '),
lastObservedAt: observedAt, ...(targetOccurrenceId ? { resolvedByOccurrenceId: targetOccurrenceId } : {}), retirement: undefined
};
return {
...obligation, state: 'awaiting-revalidation', stateReason: decision.reasons.join('; '),
lastObservedAt: observedAt, resolvedByOccurrenceId: undefined, retirement: undefined
};
}
export function retireFindingObligation(
obligation: FindingObligation,
retirement: { actor: string; reason: string; effectiveAt: string }
): FindingObligation {
const actor = retirement.actor.trim();
const reason = retirement.reason.trim();
if (!actor || !reason || !Number.isFinite(Date.parse(retirement.effectiveAt))) {
throw new Error('A scope retirement requires actor, reason and a valid effective date.');
}
return {
...obligation,
state: 'retired-out-of-scope',
stateReason: reason,
retirement: { actor, reason, effectiveAt: retirement.effectiveAt },
resolvedByOccurrenceId: undefined
};
}

View File

@ -77,6 +77,69 @@ export interface ToolSecurityScoringDefinition {
resultPopulation: 'complete' | 'findings-only' | 'unknown';
}
/**
* Every scanner must state how its findings may be ordered against each other.
* Severity is not universal: CIS-CAT delivers none at all and PingCastle carries
* only Points. A basis is declared with its exact mapped properties, or declared
* unavailable with a reason. Never infer one from an arbitrary field named
* severity, level, score or rank, and never order across sources with different
* bases — see docs/REPORT_STRATEGY.md.
*/
export interface ToolPriorityBasisDefinition {
version: string;
basis: {
kind: 'severity';
/** Exact mapped properties carrying the native criticality. */
properties: [string, ...string[]];
} | {
kind: 'numeric';
/** Exact mapped properties behind the numeric rank value. */
properties: [string, ...string[]];
scale: string;
direction: 'higher-is-worse' | 'lower-is-worse';
} | {
kind: 'unavailable';
/** Why the supported report formats cannot supply a defensible ordering. */
reason: string;
};
/**
* Fields that resemble a basis but are not one. Recorded so a later change
* cannot quietly promote them, which would manufacture a criticality the
* source never stated.
*/
excludedProperties?: Array<{ property: string; reason: string }>;
}
/**
* Versioned contract for remediation comparisons. This is deliberately
* separate from score population: a complete score denominator does not prove
* that an omitted rule or object was re-tested successfully.
*/
interface AvailableToolTimeslotComparisonBase {
version: string;
kind: 'available';
scope: {
kind: string;
properties: [string, ...string[]];
};
observationTimeProperties: [string, ...string[]];
resultEvidence: 'explicit-results' | 'findings-only' | 'unknown';
/** `unsupported` keeps missing target occurrences unresolved. */
negativeEvidence: 'unsupported' | 'complete-explicit-negative';
diagnosticPolicy: 'fail-closed-by-impact';
}
export type ToolTimeslotComparisonDefinition = (AvailableToolTimeslotComparisonBase & {
granularity: 'scope' | 'aggregate';
}) | (AvailableToolTimeslotComparisonBase & {
granularity: 'asset';
object: { kind: string; namespace: string; source: 'affected-asset' };
}) | {
version: string;
kind: 'unavailable';
reason: string;
};
export interface ToolProfileDefinition {
schemaVersion: 1;
id: string;
@ -84,6 +147,8 @@ export interface ToolProfileDefinition {
version: string;
processing: ToolProcessingContract;
securityScoring: ToolSecurityScoringDefinition;
priorityBasis: ToolPriorityBasisDefinition;
timeslotComparison: ToolTimeslotComparisonDefinition;
fallback?: boolean;
sourceSignals: ProfileSourceSignal[];
tableRules: {

View File

@ -20,7 +20,7 @@ import type { PolicyFindingOccurrence } from './policy-finding';
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
import type { ImportArtifact, ImportProcessingRun } from './ingestion-persistence';
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis, StoredGpoPolicyIdentityResolution } from './gpo-analysis-persistence';
import type { GpoPolicyObservation } from './gpo-policy';
import type { GpoPolicyObservation, GpoPreferenceContext } from './gpo-policy';
import {
POLICY_COMPARATOR_CATALOG,
POLICY_EVALUATION_ENGINE,
@ -31,6 +31,7 @@ import {
import type { StoredPolicySnapshotAggregation } from './gpo-snapshot-aggregation';
import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-contract-mapping';
import { isDeviation } from './deviation-predicate';
import { POLICY_CLASSIFICATION_LABELS } from './policy-status-labels';
import { policyComparisonDisposition } from './policy-comparison-disposition';
import { severityRank } from './severity-order';
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
@ -108,9 +109,23 @@ export interface UnifiedResultProjection {
policyValues?: JsonValue[];
policyDiagnosticCodes?: string[];
policyBackupInstanceId?: string;
/**
* Retained Group Policy Preference context. A preference is an instruction with its own
* action, item-level targeting and apply-once semantics, so it is never reduced to a
* comparable policy setting; these fields only make that retained context readable.
*/
policyPreferenceCse?: GpoPreferenceContext['cse'];
policyPreferenceAction?: GpoPreferenceContext['operation'];
policyPreferenceTargeting?: GpoPreferenceContext['targeting']['status'];
/** Retained native identities, distinct from ASSESSOR's backup-instance ID. */
policyGpoId?: string;
policyNativeBackupId?: string;
/** Retained instruction order within its source, never cross-GPO precedence. */
policySourceSequence?: number;
policyBaselineId?: string;
policyBaselineLabel?: string;
policyBaselineValue?: JsonValue;
policyBaselineVariantAssessment?: PolicyEvaluationResult['baselineVariantAssessment'];
policyActualValue?: JsonValue;
policyComparisonRelation?: string;
policyCustomerState?: PolicyEvaluationResult['customerState'];
@ -480,11 +495,11 @@ function policyEvaluationOutcome(result: PolicyEvaluationResult): UnifiedOutcome
}
function policyEvaluationClassification(result: PolicyEvaluationResult): PolicyResultClassification {
if (policyComparisonDisposition(result) === 'misconfigured') return 'misconfigured';
if (result.customerState === 'conflicted'
|| result.diagnosticCodes.includes('policy-evaluation-baseline-consensus-conflict')) return 'conflict';
if (policyComparisonDisposition(result) === 'not-configured') return 'not-configured';
if (result.classification === 'configured_match') return 'match';
if (policyComparisonDisposition(result) === 'misconfigured') return 'misconfigured';
return 'review-required';
}
@ -563,6 +578,7 @@ export function createPolicyEvaluationUnifiedResult(
policyBaselineId: result.baselineId,
policyBaselineLabel: result.baselineLabel,
policyBaselineValue: result.comparator.baseline as JsonValue | undefined,
policyBaselineVariantAssessment: result.baselineVariantAssessment,
policyActualValue: result.comparator.actual as JsonValue | undefined,
policyComparisonRelation: result.comparator.relation,
policyCustomerState: result.customerState,
@ -585,17 +601,32 @@ export function selectCurrentPolicyEvaluations(
options: { allowPreviousVersionForDisplay?: boolean } = {}
): StoredPolicyEvaluation[] {
const selected = new Map<string, StoredPolicyEvaluation>();
// Broader benchmark coverage wins, measured in the distinct baselines actually compared
// rather than in analysis references: several retained analysis generations of one
// baseline package are one benchmark, not extra coverage, and must not outrank a
// narrower current run. Where the retained results name no baseline, nothing better is
// known and the declared analysis references remain the only available measure.
const coverage = new Map<string, number>();
const baselineCoverage = (record: StoredPolicyEvaluation): number => {
const cached = coverage.get(record.run.id);
if (cached !== undefined) return cached;
const compared = new Set(record.results.flatMap(result => result.baselineId ? [result.baselineId] : []));
const value = compared.size || record.run.baselineAnalysisIds.length;
coverage.set(record.run.id, value);
return value;
};
for (const record of records) {
const isCurrent = record.run.evaluator.version === POLICY_EVALUATION_ENGINE.version;
// Read-only Work compatibility, never a completion/finding-generation signal.
const readablePrevious = options.allowPreviousVersionForDisplay && record.run.evaluator.version === '1.3.3';
const readablePrevious = options.allowPreviousVersionForDisplay && ['1.3.3', '1.4.0'].includes(record.run.evaluator.version);
if (record.run.evaluator.id !== POLICY_EVALUATION_ENGINE.id
|| (!isCurrent && !readablePrevious)) continue;
const current = selected.get(record.run.aggregationRunId);
const sameVersion = current?.run.evaluator.version === record.run.evaluator.version;
const replacesCurrent = !current || (isCurrent && !sameVersion)
|| (sameVersion && (record.run.baselineAnalysisIds.length > current.run.baselineAnalysisIds.length
|| (record.run.baselineAnalysisIds.length === current.run.baselineAnalysisIds.length
const newerPrevious = current?.run.evaluator.version === '1.3.3' && record.run.evaluator.version === '1.4.0';
const replacesCurrent = !current || (isCurrent && !sameVersion) || newerPrevious
|| (sameVersion && (baselineCoverage(record) > baselineCoverage(current!)
|| (baselineCoverage(record) === baselineCoverage(current!)
&& `${record.run.completedAt}\u001f${record.run.id}` > `${current.run.completedAt}\u001f${current.run.id}`)));
if (replacesCurrent) selected.set(record.run.aggregationRunId, record);
}
@ -718,6 +749,12 @@ export function createPolicyObservationUnifiedResult(source: PolicyObservationUn
policyValues: observation.values as unknown as JsonValue[],
policyDiagnosticCodes: diagnosticCodes,
policyBackupInstanceId: backup.instance.id,
policyPreferenceCse: observation.preference?.cse,
policyPreferenceAction: observation.preference?.operation,
policyPreferenceTargeting: observation.preference?.targeting.status,
policyGpoId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.gpoId : undefined,
policyNativeBackupId: backup.instance.identity.status === 'resolved' ? backup.instance.identity.backupId : undefined,
policySourceSequence: observation.registryPolicy?.sequence ?? observation.preference?.sequence,
categories: [{
namespace: 'group-policy',
categoryCode: observation.deliveryMechanism,
@ -913,8 +950,12 @@ export function filterUnifiedResults(items: UnifiedResultProjection[], query: Un
policyIdentityKey: item.policyIdentityKey,
policyValues: item.policyValues,
policyDiagnosticCodes: item.policyDiagnosticCodes,
policyPreferenceCse: item.policyPreferenceCse,
policyPreferenceAction: item.policyPreferenceAction,
policyPreferenceTargeting: item.policyPreferenceTargeting,
policyBaselineLabel: item.policyBaselineLabel,
policyBaselineValue: item.policyBaselineValue,
policyBaselineVariantAssessment: item.policyBaselineVariantAssessment,
policyActualValue: item.policyActualValue,
policyComparisonRelation: item.policyComparisonRelation,
policyCustomerState: item.policyCustomerState,
@ -1037,14 +1078,17 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
facet('resultType', 'Result Type', item => findingAxis(item) ? item.resultType : undefined, 'resultType'),
(() => {
const result = facet(
'policyClassification', 'GPO Classification', item => item.policyClassification,
'policyClassification', 'deviation'
'policyClassification', 'Result Status', item => item.policyClassification,
'policyClassification'
);
const labels: Record<PolicyResultClassification, string> = {
'misconfigured': 'Misconfigured', 'not-configured': 'Not configured', match: 'Match',
conflict: 'Conflict', duplicate: 'Duplicate', 'review-required': 'Review required'
};
const labels: Record<PolicyResultClassification, string> = POLICY_CLASSIFICATION_LABELS;
// A selected status must stay visible and removable even if another filter
// reduces it to zero (for example Passed together with Deviation = Yes).
for (const value of query.policyClassification ?? []) {
if (!result.values.some(item => item.value === value)) result.values.push({ value, label: labels[value], count: 0 });
}
result.values.forEach(value => { value.label = labels[value.value as PolicyResultClassification] ?? value.label; });
result.values.sort((left, right) => left.label.localeCompare(right.label));
return result;
})(),
facet('isIoe', 'IOE', item => findingAxis(item) ? String(item.isIoe) : undefined, 'isIoe'),

View File

@ -0,0 +1,102 @@
import type { GpoPolicyValue } from '../domain/gpo-policy';
import type { MicrosoftSecurityBaselineSetting } from '../domain/microsoft-security-baseline-analysis';
import {
policyBaselineVariantDecision,
type PolicyBaselineVariantAssessment,
type PolicyComparableValue,
type PolicyComparatorDefinition,
type PolicyComparatorOutcome
} from '../domain/policy-evaluation';
import { canonicalJson } from '../shared/canonical-json';
import { comparePolicyValues, policyComparableValue } from './policy-comparator';
const supportedRegistryOperations = new Set(['set-value', 'delete-value', 'delete-all-values', 'create-key']);
/** Compare one retained member without rolling up the whole technical setting. */
export function comparePolicyValueToBaselineVariant(
actual: GpoPolicyValue, baseline: GpoPolicyValue, comparator: PolicyComparatorDefinition
): PolicyComparatorOutcome {
let member = comparePolicyValues(actual, baseline, comparator);
if (actual.kind === 'registry-policy' && !supportedRegistryOperations.has(actual.operation)) {
member = { ...member, relation: 'incomparable', diagnosticCodes: ['policy-comparator-customer-value-ambiguous'] };
} else if (baseline.kind === 'registry-policy' && !supportedRegistryOperations.has(baseline.operation)) {
member = { ...member, relation: 'incomparable', diagnosticCodes: ['policy-comparator-baseline-unavailable'] };
} else if (actual.kind === 'registry-policy' && baseline.kind === 'registry-policy'
&& member.relation !== 'incomparable'
&& actual.operation === 'set-value' && baseline.operation === 'set-value'
&& (actual.typeCode !== baseline.typeCode || (actual.data?.kind === 'multi-string'
&& canonicalJson(actual.data) !== canonicalJson(baseline.data)))) {
// REG_DWORD/REG_QWORD are not interchangeable; REG_MULTI_SZ order is meaningful.
member = { ...member, relation: 'different' };
}
return member;
}
function compareMissingBaselineValue(baseline: GpoPolicyValue) {
const compared = comparePolicyValues(undefined, baseline, { kind: 'equality' });
const expected = policyComparableValue(baseline);
const deletionSatisfied = baseline.kind === 'registry-policy'
&& (baseline.operation === 'delete-value' || baseline.operation === 'delete-all-values');
return {
...compared,
actual: deletionSatisfied ? { kind: 'registry-state' as const, state: 'absent' as const } : undefined,
baseline: expected,
relation: deletionSatisfied ? 'match' as const : 'different' as const,
diagnosticCodes: []
};
}
function compareRegistryTargetState(
actual: Extract<PolicyComparableValue, { kind: 'registry-state' }>,
baseline: GpoPolicyValue
): PolicyComparatorOutcome | undefined {
const expected = policyComparableValue(baseline);
if (expected?.kind !== 'registry-state') return undefined;
return { version: 1, comparator: { kind: 'equality' }, actual, baseline: expected,
relation: canonicalJson(actual) === canonicalJson(expected) ? 'match' : 'different', diagnosticCodes: [] };
}
/** Exact configuration comparison. No implicit "stronger is compliant" or role inference. */
export function compareBaselineVariants(
actualValues: GpoPolicyValue[], settings: MicrosoftSecurityBaselineSetting[], missing: boolean, blockers: string[],
actualRegistryState?: Extract<PolicyComparableValue, { kind: 'registry-state' }>
): PolicyBaselineVariantAssessment {
const variants = [...settings].sort((a, b) => a.id.localeCompare(b.id)).map(setting => {
const baselineValues = structuredClone(setting.observation.values);
const baseline = baselineValues[0];
let comparator = actualRegistryState && baseline
? compareRegistryTargetState(actualRegistryState, baseline) ?? comparePolicyValues(actualValues[0], baseline, { kind: 'equality' })
: missing && baseline ? compareMissingBaselineValue(baseline)
: comparePolicyValues(actualValues[0], baseline, { kind: 'equality' });
const usableBaseline = baselineValues.length === 1 && comparator.baseline && comparator.baseline.kind !== 'none'
&& !(baseline.kind === 'registry-policy' && !supportedRegistryOperations.has(baseline.operation))
&& setting.observation.state !== 'conflicted';
if (!usableBaseline) {
comparator = { ...comparator, relation: 'incomparable', diagnosticCodes: ['policy-comparator-baseline-unavailable'] };
} else if (!missing && !actualRegistryState) {
const members = actualValues.map(actual => comparePolicyValueToBaselineVariant(actual, baseline, { kind: 'equality' }));
// A single proven failing member makes the technical setting fail. An
// unresolved member blocks only when no independently comparable member fails.
const relation = members.some(member => member.relation === 'different') ? 'different'
: members.length && members.every(member => member.relation === 'match') ? 'match' : 'incomparable';
comparator = {
...comparator,
relation,
actual: members.length === 1 ? members[0].actual : undefined,
diagnosticCodes: [...new Set(members.flatMap(member => member.diagnosticCodes))].sort()
};
}
return { settingId: setting.id, baselineValues, comparator };
});
return { version: 1, scope: 'backup-configuration', actualValues: structuredClone(actualValues), variants,
decision: policyBaselineVariantDecision(variants, blockers), diagnosticCodes: [...blockers].sort() };
}
export function baselineVariantSummary(assessment: PolicyBaselineVariantAssessment): string {
const count = assessment.variants.length;
const suffix = ` (${count} assessed baseline variant${count === 1 ? '' : 's'}; backup configuration only)`;
if (assessment.decision === 'all-pass') return 'Matches every assessed baseline variant' + suffix;
if (assessment.decision === 'all-fail') return 'Differs from every assessed baseline variant' + suffix;
if (assessment.decision === 'mixed') return 'Review required: baseline variant results differ' + suffix;
return 'Review required: comparison evidence or applicability is incomplete' + suffix;
}

View File

@ -24,7 +24,13 @@ 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);
if (value.kind === 'registry-policy') {
if (value.operation === 'delete-value') return { kind: 'registry-state', state: 'absent' };
if (value.operation === 'delete-all-values') return { kind: 'registry-state', state: 'key-empty' };
if (value.operation === 'create-key') return { kind: 'registry-state', state: 'key-present' };
if (value.operation !== 'set-value') return undefined;
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.

View File

@ -10,11 +10,13 @@ import { buildPolicyWorkResults } from './policy-work-results';
export function createPolicyConsistencyUnifiedResults(
observations: UnifiedResultProjection[]
): UnifiedResultProjection[] {
// The diagnostic travels as a flag on the setting's own row; it is not a verdict,
// so a consistency finding is selected by the flag rather than by the row status.
return buildPolicyWorkResults(observations, [])
.filter(result => result.status === 'Conflicts' || result.status === 'Duplicates')
.filter(result => result.conflict || result.duplicate)
.map(result => {
const first = result.sources[0];
const classification: PolicyResultClassification = result.status === 'Conflicts' ? 'conflict' : 'duplicate';
const classification: PolicyResultClassification = result.conflict ? 'conflict' : 'duplicate';
return {
resultProjectionId: `policy-consistency:${encodeURIComponent(result.id)}`,
sourceKind: 'policy-evaluation',

View File

@ -5,11 +5,33 @@ 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 PolicyWorkStatus = 'Fail' | 'Pass' | 'Conflicts' | 'Duplicates' | 'Not configured'
| 'Review required' | 'No baseline rule' | 'Not comparable';
export type PolicyWorkCounts = Record<PolicyWorkStatus, number>;
/** Shared setting verdict order for counters and the Work row projection. */
export const policyWorkVerdictPriority: Record<PolicyWorkStatus, number> = {
Fail: 6, 'Review required': 5, Pass: 4, 'Not configured': 3, 'No baseline rule': 2,
'Not comparable': 1, Conflicts: 0, Duplicates: 0
};
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]++;
const counts: PolicyWorkCounts = { Fail: 0, Pass: 0, Conflicts: 0, Duplicates: 0, 'Not configured': 0,
'Review required': 0, 'No baseline rule': 0, 'Not comparable': 0 };
const conflictKeys = new Set<string>();
const duplicateKeys = new Set<string>();
const verdicts = new Map<string, Exclude<PolicyWorkStatus, 'Conflicts' | 'Duplicates'>>();
for (const result of results) {
const diagnosticKey = result.settingKey ?? JSON.stringify([
result.sources[0]?.projectId, result.sources[0]?.assessmentId, result.scope, result.identity
]);
if (result.conflict || result.status === 'Conflicts') conflictKeys.add(diagnosticKey);
if (result.duplicate || result.status === 'Duplicates') duplicateKeys.add(diagnosticKey);
if (result.status === 'Conflicts' || result.status === 'Duplicates') continue;
const previous = verdicts.get(diagnosticKey);
if (!previous || policyWorkVerdictPriority[result.status] > policyWorkVerdictPriority[previous]) verdicts.set(diagnosticKey, result.status);
}
verdicts.forEach(verdict => { counts[verdict] += 1; });
counts.Conflicts = conflictKeys.size;
counts.Duplicates = duplicateKeys.size;
return counts;
}
export interface PolicyWorkResult {
@ -22,6 +44,11 @@ export interface PolicyWorkResult {
evaluatedAt?: string;
sources: UnifiedResultProjection[];
comparison?: PolicyEvaluationResult;
/** Stable scope for one verdict per technical setting within an aggregation/timeslot. */
settingKey?: string;
/** Diagnostic categories can coexist with the authoritative verdict. */
conflict?: boolean;
duplicate?: boolean;
}
export function policyMisconfigurationImpact(results: PolicyWorkResult[]): { settings: number; backups: number } {
@ -30,12 +57,19 @@ export function policyMisconfigurationImpact(results: PolicyWorkResult[]): { set
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]));
const backupKey = policyBackupImpactKey(source);
if (backupKey) backups.add(backupKey);
}
}
return { settings: settings.size, backups: backups.size };
}
/** Shared identity for the Processing count and its Work provenance filter. */
export function policyBackupImpactKey(source: UnifiedResultProjection): string | undefined {
return source.policyBackupInstanceId
? JSON.stringify([source.projectId, source.assessmentId, source.policyBackupInstanceId]) : undefined;
}
const fold = (value: string) => value.replace(/\//g, '\\').toLowerCase();
/** A value signature excludes location/provenance but retains the registry type. */
@ -64,6 +98,42 @@ function address(source: UnifiedResultProjection): string | undefined {
? `${source.policyScope}:${source.policyIdentityKey}` : undefined;
}
/** One row per technical setting of one assessment, matching the evaluated settingKey shape. */
function unevaluatedSettingKey(source: UnifiedResultProjection): string {
return JSON.stringify([source.projectId, source.assessmentId, source.policyScope, source.policyIdentityKey]);
}
/**
* A retained setting whose delivery mechanism has no comparable technical policy identity.
* Today this is always a Group Policy Preference: its action, item-level targeting and
* apply-once semantics differ from a policy setting, so it must never be compared with a
* Microsoft baseline recommendation. It still belongs in Work as inspectable evidence.
*/
function notComparableResults(
sources: UnifiedResultProjection[]
): PolicyWorkResult[] {
const groups = new Map<string, UnifiedResultProjection[]>();
for (const source of sources) {
if (source.policyIdentityStatus === 'resolved' || !source.policyIdentityKey || !source.policyScope) continue;
const key = unevaluatedSettingKey(source);
groups.set(key, [...(groups.get(key) ?? []), source]);
}
return [...groups].map(([key, group]) => ({
id: `not-comparable:${key}`,
status: 'Not comparable' as const,
title: group[0].title,
scope: group[0].policyScope!,
identity: group[0].policyIdentityKey!,
importedAt: latestImport(group),
sources: group,
settingKey: key,
// Preference actions and item-level targeting are not value assignments, so identical
// instructions are not proven duplicates and differing ones are not proven conflicts.
conflict: false,
duplicate: false
}));
}
function comparisonTitle(result: PolicyEvaluationResult): string {
const target = result.identity.target;
return target.valueName || ('section' in target ? `${target.section} · ${target.key}` : target.key);
@ -73,6 +143,60 @@ function latestImport(sources: UnifiedResultProjection[]): string {
return sources.reduce((latest, source) => source.importedAt > latest ? source.importedAt : latest, '');
}
/** Retain a proved cross-GPO difference even if an additional GPO is indeterminate. */
function hasProvenIndeterminateConflict(comparison: PolicyEvaluationResult, evidence: UnifiedResultProjection[]): boolean {
const retained = new Set((comparison.baselineVariantAssessment?.actualValues ?? [])
.map(policySettingValueSignature).filter((signature): signature is string => signature !== undefined));
if (retained.size < 2) return false;
const byBackup = new Map<string, Set<string | undefined>>();
for (const source of evidence) {
const key = policyBackupImpactKey(source);
if (!key) continue;
const signatures = byBackup.get(key) ?? new Set<string | undefined>();
if (!source.policyValues?.length || !['configured', 'instruction'].includes(source.policyState ?? '')) {
signatures.add(undefined);
}
for (const value of (source.policyValues ?? []) as GpoPolicyValue[]) signatures.add(policySettingValueSignature(value));
byBackup.set(key, signatures);
}
const proven = new Set<string>();
for (const signatures of byBackup.values()) {
// An uncertain backup can retain earlier writes in actualValues. Never
// choose its last raw observation or treat its instructions as independent GPOs.
if (signatures.size !== 1 || signatures.has(undefined)) continue;
const signature = signatures.values().next().value!;
if (retained.has(signature)) proven.add(signature);
}
return proven.size > 1;
}
/** Versioned evaluations retain the customer state after per-backup processing. */
function evaluatedDiagnosticFlags(
comparison: PolicyEvaluationResult, evidence: UnifiedResultProjection[],
consistency?: { status: 'Conflicts' | 'Duplicates' }
): Pick<PolicyWorkResult, 'conflict' | 'duplicate'> {
if (!comparison.baselineVariantAssessment) {
// Preserve the historical evidence-only fallback for pre-variant results.
return { conflict: consistency?.status === 'Conflicts', duplicate: consistency?.status === 'Duplicates' };
}
const sameSetting = evidence.length > 0 && evidence.every(source =>
source.policyScope === comparison.identity.scope && source.policyIdentityKey === comparison.identity.id);
const backups = new Set(evidence.flatMap(source => {
const key = policyBackupImpactKey(source);
return key ? [key] : [];
}));
const conflict = comparison.customerState === 'conflicted'
|| (comparison.customerState === 'indeterminate' && sameSetting && backups.size > 1
&& hasProvenIndeterminateConflict(comparison, evidence));
return {
conflict,
// Multiple instructions in one GPO are not duplicate GPO contributions.
// Key-level comparisons can include distinct child settings, not duplicates.
duplicate: comparison.customerState === 'configured' && sameSetting && backups.size > 1
&& evidence.every(source => Boolean(source.policyBackupInstanceId))
};
}
/** Explicit comparison outcomes; absent settings remain separate from misconfiguration. */
export function buildPolicyWorkResults(
observations: UnifiedResultProjection[], evaluations: StoredPolicyEvaluation[]
@ -90,17 +214,15 @@ export function buildPolicyWorkResults(
groups.set(key, group);
}
const output: PolicyWorkResult[] = [];
const conflictedIds = new Set<string>();
const consistencyGroups: Array<{ key: string; group: UnifiedResultProjection[]; status: 'Conflicts' | 'Duplicates' }> = [];
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: latestImport(group), sources: group });
consistencyGroups.push({ key, group, status });
}
const consumedConsistencyGroups = new Set<string>();
const seen = new Set<string>();
for (const evaluation of evaluations) {
// Missing customer settings have no direct observations. Their import date
@ -116,24 +238,80 @@ export function buildPolicyWorkResults(
// 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 evidenceIds = new Set(comparison.observationIds);
const matchedConsistency = consistencyGroups.filter(item => item.group.some(source => evidenceIds.has(source.sourceResultId)));
const consistency = matchedConsistency[0];
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';
if (comparison.baselineVariantAssessment) {
status = policyComparisonDisposition(comparison) === 'not-configured' ? 'Not configured'
: policyComparisonDisposition(comparison) === 'misconfigured' ? 'Fail'
: comparison.baselineVariantAssessment.decision === 'all-pass'
&& comparison.classification === 'configured_match' ? 'Pass' : 'Review required';
}
else 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;
// A retained comparison that yields no safe verdict stays visible for review. It must
// never be dropped: an applicable baseline rule exists, so it is not 'No baseline rule'.
else status = 'Review required';
output.push({ id, status, title: comparisonTitle(comparison), scope: comparison.identity.scope,
identity: comparison.identity.id, importedAt: latestImport(evidence) || evaluationImportedAt,
evaluatedAt: evaluation.run.completedAt, sources: evidence, comparison });
evaluatedAt: evaluation.run.completedAt, sources: evidence, comparison,
settingKey: JSON.stringify([evaluation.run.projectId, evaluation.run.aggregationRunId,
comparison.identity.scope, comparison.identity.id]),
...evaluatedDiagnosticFlags(comparison, evidence, consistency) });
// Do not emit a second raw-source diagnosis for an already evaluated
// setting, including superseded instructions and contributing child keys.
for (const item of matchedConsistency) consumedConsistencyGroups.add(item.key);
}
}
// Every resolved customer setting reaches Work. A retained state whose technical
// identity has no applicable baseline rule cannot produce a Pass/Fail verdict, but it
// is evidence, not a gap: it stays inspectable, filterable and score-neutral.
const evaluatedIdentities = new Set(evaluations.flatMap(evaluation =>
evaluation.results.map(result => result.identity.id)));
// A key-level requirement is evidenced by its child values. Those children carry their
// own technical identity, so coverage follows the retained evidence as well as the
// identity; otherwise the same observation would be reported twice.
const evaluatedObservationIds = new Set(evaluations.flatMap(evaluation =>
evaluation.results.flatMap(result => result.observationIds)));
const unevaluated = new Map<string, UnifiedResultProjection[]>();
for (const source of sources) {
if (source.policyIdentityStatus !== 'resolved' || !source.policyIdentityKey) continue;
if (evaluatedIdentities.has(source.policyIdentityKey)) continue;
if (evaluatedObservationIds.has(source.sourceResultId)) continue;
const key = unevaluatedSettingKey(source);
unevaluated.set(key, [...(unevaluated.get(key) ?? []), source]);
}
const unevaluatedSourceIds = new Set([...unevaluated.values()].flat().map(source => source.sourceResultId));
const unevaluatedFlags = new Map<string, 'Conflicts' | 'Duplicates'>();
for (const { key, group, status } of consistencyGroups) {
if (consumedConsistencyGroups.has(key)) continue;
// A diagnostic flag is never an alternative verdict. Where the group belongs to one
// resolved setting, it marks that setting's own row instead of replacing it.
const settingKeys = new Set(group.map(source => unevaluatedSourceIds.has(source.sourceResultId)
? unevaluatedSettingKey(source) : undefined));
const settingKey = settingKeys.size === 1 ? [...settingKeys][0] : undefined;
if (settingKey && unevaluated.has(settingKey)) { unevaluatedFlags.set(settingKey, status); continue; }
output.push({ id: `${status}:${key}`, status, title: group[0].title,
scope: group[0].policyScope!, identity: group[0].policyIdentityKey ?? key,
importedAt: latestImport(group), sources: group, settingKey: key,
conflict: status === 'Conflicts', duplicate: status === 'Duplicates' });
}
for (const [key, group] of unevaluated) {
const flag = unevaluatedFlags.get(key);
output.push({ id: `no-baseline-rule:${key}`, status: 'No baseline rule', title: group[0].title,
scope: group[0].policyScope!, identity: group[0].policyIdentityKey!,
importedAt: latestImport(group), sources: group, settingKey: key,
conflict: flag === 'Conflicts', duplicate: flag === 'Duplicates' });
}
output.push(...notComparableResults(sources));
return output;
}

View File

@ -0,0 +1,493 @@
import type {
ReportDomainPriority,
ReportDomainSummary,
ReportFindingRef,
ReportGpoBlock,
ReportPriorityBasis,
ReportProjection,
ReportScoreSource
} from '../domain/report-projection';
import { escapeHtml } from '../shared/html';
/**
* Artifact A of docs/REPORT_STRATEGY.md: the Management Overview.
*
* A frozen snapshot for the customer's decision makers. It answers how the
* environment stands, what is most urgent, what the statement rests on and what
* is not known — and deliberately carries no registry keys, SIDs, evidence paths
* or parser versions, so the file can be forwarded as it is.
*
* German carries the prose; established English technical terminology stays
* English. The file is self-contained: every style is inline, nothing is
* fetched when it is opened.
*/
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0';
const DASH = '—';
/** Unknown stays unknown. An absent value never becomes a zero. */
function value(input: string | number | undefined | null): string {
if (input === undefined || input === null) return DASH;
if (typeof input === 'number' && !Number.isFinite(input)) return DASH;
const text = String(input).trim();
return text === '' ? DASH : escapeHtml(text);
}
function integer(input: number | undefined): string {
return typeof input === 'number' && Number.isFinite(input)
? escapeHtml(input.toLocaleString('de-DE'))
: DASH;
}
function percent(ratio: number | undefined, digits = 0): string {
return typeof ratio === 'number' && Number.isFinite(ratio)
? escapeHtml((ratio * 100).toLocaleString('de-DE', {
minimumFractionDigits: digits, maximumFractionDigits: digits
}))
: DASH;
}
function germanDateTime(iso: string): string {
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return value(iso);
return escapeHtml(new Date(parsed).toLocaleString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
}));
}
function germanDate(iso: string | undefined): string {
if (!iso) return DASH;
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return value(iso);
return escapeHtml(new Date(parsed).toLocaleDateString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit'
}));
}
/**
* Binding wording from docs/SCORING_MODEL.md. A method is named, never implied,
* and the GPO contribution is never called Baseline-Compliance.
*/
const SCORE_METHOD_LABEL: Record<ReportScoreSource['method'], string> = {
native: 'Nativer Score des Werkzeugs',
'inverted-native': 'Nativer Risk, invertiert als 100 − Risk',
'pass-rate': 'Pass Rate der bewerteten Checks',
unavailable: 'Kein verwertbarer Score'
};
/** Names why a source is ordered the way it is, or why it is not ordered. */
function basisLabel(basis: ReportPriorityBasis): string {
if (basis.kind === 'severity') return 'Sortiert nach nativer Severity';
if (basis.kind === 'numeric') {
const direction = basis.direction === 'higher-is-worse' ? 'höher ist kritischer' : 'niedriger ist kritischer';
return `Sortiert nach ${escapeHtml(basis.property)} (${escapeHtml(basis.scale)}, ${direction})`;
}
if (basis.kind === 'equal-weight') return `Ohne Rangfolge: ${escapeHtml(basis.reason)}`;
return 'Ohne Rangfolge: Die Quelle deklariert keinen belastbaren Maßstab.';
}
const SEVERITY_LABEL: Record<ReportFindingRef['severity'], string> = {
critical: 'Critical', high: 'High', medium: 'Medium',
low: 'Low', info: 'Info', unknown: 'Unknown'
};
/**
* Shared donut geometry: full 360 degrees, continuous neutral track, value arc
* clockwise from twelve o'clock, flat caps. The number sits on the exact centre
* and its unit follows to the right without displacing it.
*/
function donut(score: number | undefined, caption: string): string {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const ratio = typeof score === 'number' && Number.isFinite(score)
? Math.min(Math.max(score, 0), 100) / 100
: 0;
const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`;
const display = typeof score === 'number' && Number.isFinite(score)
? Math.round(score).toLocaleString('de-DE')
: DASH;
const hasScore = display !== DASH;
return `<figure class="donut">
<svg viewBox="0 0 128 128" role="img" aria-label="Overall Score ${hasScore ? `${display} von 100` : 'nicht verfügbar'}">
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>
${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}"
stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>` : ''}
</svg>
<figcaption class="donut__readout">
<span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span>
<span class="donut__caption">${escapeHtml(caption)}</span>
</figcaption>
</figure>`;
}
/** Domain pass rates as a connected spine, not a conventional table. */
function domainSpine(domains: ReportDomainSummary[]): string {
if (!domains.length) return '<p class="empty">Keine Security Domain wurde bewertet.</p>';
return `<ol class="spine">${domains.map(domain => {
const known = typeof domain.passRate === 'number';
const width = known ? Math.round(domain.passRate! * 100) : 0;
return `<li class="spine__item">
<span class="spine__node" aria-hidden="true"></span>
<span class="spine__label">${escapeHtml(domain.domainLabel)}</span>
<span class="spine__meter"><span class="spine__fill" style="width:${width}%"></span></span>
<span class="spine__figure">${known ? `${percent(domain.passRate)} %` : DASH}</span>
<span class="spine__counts">${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet</span>
</li>`;
}).join('')}</ol>`;
}
function findingRow(finding: ReportFindingRef, ranked: boolean): string {
const severity = finding.severity;
const showSeverity = severity !== 'unknown';
return `<li class="finding">
<span class="finding__accent finding__accent--${escapeHtml(severity)}" aria-hidden="true"></span>
<span class="finding__title">${escapeHtml(finding.title)}</span>
<span class="finding__meta">
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''}
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
${finding.affectedAsset ? `<span class="finding__asset">${escapeHtml(finding.affectedAsset)}</span>` : ''}
</span>
</li>`;
}
function prioritySection(priorities: ReportDomainPriority[]): string {
if (!priorities.length) return '<p class="empty">Es wurde keine Deviation festgestellt.</p>';
return priorities.map(domain => `<article class="tile domain">
<header class="domain__head">
<h3>${escapeHtml(domain.domainLabel)}</h3>
<span class="domain__count">${integer(domain.deviations)} Deviations</span>
</header>
${domain.sources.map(source => `<section class="source">
<header class="source__head">
<span class="source__tool">${escapeHtml(source.toolId)}</span>
<span class="source__basis">${basisLabel(source.basis)}</span>
</header>
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked)).join('')}</ol>
${source.shown.length < source.total
? `<p class="source__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
: ''}
</section>`).join('')}
</article>`).join('');
}
/**
* GPO deviations carry no native criticality, so they form their own
* equal-weight block instead of being ranked against severity-bearing sources.
*/
function gpoSection(gpo: ReportGpoBlock): string {
const rows: Array<[string, number]> = [
['Fail', gpo.counts.Fail],
['Review required', gpo.counts['Review required']],
['Pass', gpo.counts.Pass],
['Not configured', gpo.counts['Not configured']],
['No baseline rule', gpo.counts['No baseline rule']],
['Not comparable', gpo.counts['Not comparable']]
];
return `<article class="tile domain">
<header class="domain__head">
<h3>GPO &amp; Baseline</h3>
<span class="domain__count">${integer(gpo.counts.Fail)} Deviations</span>
</header>
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
<dl class="verdicts">${rows.map(([label, count]) => `<div class="verdicts__row">
<dt>${escapeHtml(label)}</dt><dd>${integer(count)}</dd>
</div>`).join('')}</dl>
<p class="note">
Betroffene Policy Settings: <strong>${integer(gpo.affectedSettings)}</strong> ·
betroffene GPO Backups: <strong>${integer(gpo.affectedBackups)}</strong> ·
Conflicts: <strong>${integer(gpo.conflicts)}</strong> ·
Duplicates: <strong>${integer(gpo.duplicates)}</strong>
</p>
<p class="note">
<code>Not configured</code>, <code>No baseline rule</code> und <code>Not comparable</code>
sind score-neutral und begründen keine Maßnahme.
${gpo.scopeLimited
? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz und trifft keine Aussage über die gesamte Domäne.'
: ''}
</p>
</article>`;
}
export function buildManagementOverviewHtml(projection: ReportProjection): string {
const facts = projection.environment.length
? projection.environment
: [{ key: 'Umgebung', value: undefined }];
const remediation = projection.remediation;
const coverage = projection.coverage;
const head = `<header class="doc__head">
<p class="doc__kicker">ASSESSOR · Management Overview</p>
<h1>${escapeHtml(projection.project.name)}</h1>
<dl class="facts">
${facts.map(fact => `<div class="facts__item">
<dt>${escapeHtml(fact.key)}</dt><dd>${value(fact.value)}</dd>
</div>`).join('')}
<div class="facts__item"><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
<div class="facts__item"><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
</dl>
</header>`;
const posture = `<section class="segment" aria-labelledby="posture">
<h2 id="posture">Overall Posture</h2>
<div class="posture">
<div class="tile posture__score">
${donut(projection.overallScore, 'Overall Score')}
</div>
<div class="tile posture__domains">
<h3>Security Domains</h3>
${domainSpine(projection.domains)}
</div>
</div>
<p class="note">
Der Overall Score ist ein Mixed-Method-Index über die unten genannten Quellen.
Der GPO-Anteil bezeichnet die Konformität der konfigurierten, vergleichbaren
Einstellungen im Scope — er ist keine Aussage über eine vollständig
ausgerollte Microsoft Baseline. Security-Domain-Werte sind Pass Rates und keine
Zerlegung des Index.
</p>
</section>`;
const priorities = `<section class="segment" aria-labelledby="priorities">
<h2 id="priorities">Priority Findings</h2>
<p class="note">
Gruppiert nach Security Domain. Innerhalb einer Domain wird je Quelle nach deren
eigenem, deklariertem Maßstab geordnet; eine quellenübergreifende Rangfolge
gibt es nicht, weil die Maßstäbe nicht vergleichbar sind.
</p>
<div class="segment__body">
${prioritySection(projection.priorities)}
${projection.gpo ? gpoSection(projection.gpo) : ''}
</div>
</section>`;
const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation">
<h2 id="remediation">Remediation-Status</h2>
<p class="note">
Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind
<strong>kein technischer Nachweis</strong>: Eine Maßnahme gilt erst dann als
behoben, wenn ein Rescan das belegt.
</p>
<div class="segment__body">
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.items)}</span><span class="kpi__label">Maßnahmen in Arbeit</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutOwner)}</span><span class="kpi__label">ohne Owner</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutDue)}</span><span class="kpi__label">ohne Termin</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.riskAccepted)}</span><span class="kpi__label">Accepted Risk</span></article>
</div>
${Object.keys(remediation.byStatus).length ? `<dl class="verdicts">${Object.entries(remediation.byStatus)
.map(([status, count]) => `<div class="verdicts__row"><dt>${escapeHtml(status)}</dt><dd>${integer(count)}</dd></div>`)
.join('')}</dl>` : ''}
</section>` : '';
const coverageSection = `<section class="segment" aria-labelledby="coverage">
<h2 id="coverage">Abdeckung und Lücken</h2>
<div class="segment__body">
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.assessed)}</span><span class="kpi__label">bewertet</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.deviations)}</span><span class="kpi__label">Deviations</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notRun)}</span><span class="kpi__label">not run</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notApplicable)}</span><span class="kpi__label">not applicable</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.executionErrors)}</span><span class="kpi__label">execution errors</span></article>
</div>
<p class="note">
Von insgesamt ${integer(coverage.total)} Ergebnissen konnten ${integer(coverage.assessed)}
bewertet werden. Nicht ausgeführte, nicht anwendbare und fehlerhaft ausgeführte
Checks zählen weder als bestanden noch als Deviation.
</p>
<table class="table">
<caption>Abdeckung je Quelle</caption>
<thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th></tr></thead>
<tbody>${coverage.perSource.map(source => `<tr>
<td>${escapeHtml(source.toolId)}</td>
<td class="num">${integer(source.total)}</td>
<td class="num">${integer(source.assessed)}</td>
<td class="num">${integer(source.notRun)}</td>
</tr>`).join('')}</tbody>
</table>
</section>`;
const sources = `<section class="segment" aria-labelledby="sources">
<h2 id="sources">Source Overview</h2>
<table class="table">
<caption>Score-Beitrag je Quelle</caption>
<thead><tr>
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col">Beitrag</th>
<th scope="col">Methode</th><th scope="col">Pass</th><th scope="col">Deviations</th>
</tr></thead>
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
<td>${escapeHtml(source.toolId)}</td>
<td>${germanDate(source.observedAt)}</td>
<td class="num">${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}</td>
<td>${escapeHtml(SCORE_METHOD_LABEL[source.method])}</td>
<td class="num">${integer(source.passed)}</td>
<td class="num">${integer(source.deviations)}</td>
</tr>`).join('') : `<tr><td colspan="6">${DASH}</td></tr>`}</tbody>
</table>
${projection.scoreSources.some(source => source.method === 'inverted-native')
? '<p class="note">Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als <code>100 − Risk</code> in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.</p>'
: ''}
</section>`;
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Management Overview · ${escapeHtml(projection.project.name)}</title>
<style>
:root{
--font-ui:"Inter Variable","Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
--type-h1-size:56px; --type-h2-size:32px; --type-h3-size:18px; --type-h4-size:16px;
--type-body-size:16px; --type-table-body-size:14px; --type-label-size:12px;
--type-meta-size:12px; --type-badge-size:11px; --type-table-head-size:12px;
--fw-regular:400; --fw-semibold:600; --fw-bold:700;
--lh-ui:1.35; --lh-prose:1.55; --ls-caps:0.06em;
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px;
--space-6:24px; --space-8:32px;
--panel-content-inset:20px; --panel-inset:var(--panel-content-inset);
--segment-surface:transparent; --segment-border:transparent; --segment-radius:10px;
--segment-inset:var(--space-3); --segment-gap:var(--space-3);
--tile-radius:0px; --tile-inset:var(--space-4); --tile-border:transparent;
--radius-xs:0px; --radius-sm:0px;
--donut-ring-size:128px; --donut-ring-stroke:12px;
--donut-value-size:26px; --donut-percent-size:14px;
/* Light is the document default: a report is read and printed on white. */
--surface-app:#F1F1F4; --surface-panel:#FFFFFF; --tile-surface:#FFFFFF;
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F;
--border-subtle:#E2E2E8; --border-strong:#A8A8B4;
--donut-track:#B9BEC7; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9;
--marking-color:#E9A63C;
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700;
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#6E6E78; --sev-on:#FFFFFF;
}
@media (prefers-color-scheme:dark){
:root{
--surface-app:#242426; --surface-panel:#242426; --tile-surface:#323238;
--text-primary:#E2DFE6; --text-secondary:#B7BAC0; --text-muted:#B7BAC0;
--border-subtle:rgba(255,255,255,.14); --border-strong:#4E4E58;
--donut-track:#555B65; --donut-value:#E2DFE6; --donut-label:#B7BAC0; --donut-all:#348CF2;
}
}
*{box-sizing:border-box}
html{background:var(--surface-app)}
body{margin:0;padding:var(--panel-inset);background:var(--surface-app);color:var(--text-primary);
font:var(--fw-regular) var(--type-body-size)/var(--lh-prose) var(--font-ui)}
h1,h2,h3{margin:0;line-height:var(--lh-ui);font-weight:var(--fw-semibold)}
h1{font-size:var(--type-h1-size);letter-spacing:-.02em}
h2{font-size:var(--type-h2-size)}
h3{font-size:var(--type-h3-size)}
code{font-family:var(--font-ui);font-size:var(--type-table-body-size)}
strong{font-weight:var(--fw-semibold)}
.doc__head{padding-bottom:var(--space-6);border-bottom:1px solid var(--border-strong)}
.doc__kicker{margin:0 0 var(--space-2);font-size:var(--type-label-size);font-weight:var(--fw-semibold);
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
.facts{display:flex;flex-wrap:wrap;gap:var(--space-6);margin:var(--space-5) 0 0}
.facts__item{margin:0}
.facts dt{font-size:var(--type-label-size);font-weight:var(--fw-semibold);color:var(--text-secondary)}
.facts dd{margin:var(--space-1) 0 0;font-size:var(--type-table-body-size)}
.segment{margin-top:var(--space-8);padding:var(--segment-inset);background:var(--segment-surface);
border:1px solid var(--segment-border);border-radius:var(--segment-radius)}
.segment__body{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:flex-start}
.tile{padding:var(--tile-inset);background:var(--tile-surface);border:1px solid var(--tile-border);
border-radius:var(--tile-radius)}
.note{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-secondary)}
.empty{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-muted)}
.posture{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:stretch}
.posture__score{display:flex;align-items:center;justify-content:center;min-width:260px}
.posture__domains{flex:1 1 420px;min-width:320px}
.donut{position:relative;margin:0;width:var(--donut-ring-size);height:var(--donut-ring-size)}
.donut svg{width:100%;height:100%;display:block}
.donut circle{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt}
.donut__track{stroke:var(--donut-track)}
.donut__value{stroke:var(--donut-all)}
.donut__readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:var(--space-1);text-align:center}
.donut__number{font-size:var(--donut-value-size);font-weight:var(--fw-semibold);color:var(--donut-value);
line-height:1;font-variant-numeric:tabular-nums}
.donut__unit{font-size:var(--donut-percent-size);font-weight:var(--fw-regular);color:var(--donut-label)}
.donut__caption{font-size:var(--type-badge-size);color:var(--donut-label)}
.spine{list-style:none;margin:var(--space-4) 0 0;padding:0 0 0 var(--space-4);
border-left:1px solid var(--border-subtle)}
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,140px) 52px;
gap:var(--space-2) var(--space-3);align-items:center;padding:var(--space-2) 0}
.spine__node{position:absolute;left:calc(var(--space-4) * -1 - 4px);width:7px;height:7px;border-radius:50%;
background:var(--donut-all)}
.spine__label{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
.spine__meter{height:6px;background:var(--donut-track)}
.spine__fill{display:block;height:100%;background:var(--donut-all)}
.spine__figure{font-size:var(--type-table-body-size);font-variant-numeric:tabular-nums;text-align:right}
.spine__counts{grid-column:1 / -1;font-size:var(--type-meta-size);color:var(--text-secondary)}
.domain{flex:1 1 460px;min-width:320px}
.domain__head{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-3);
padding-bottom:var(--space-2);border-bottom:1px solid var(--border-subtle)}
.domain__count{font-size:var(--type-meta-size);color:var(--text-secondary);font-variant-numeric:tabular-nums}
.source{margin-top:var(--space-4)}
.source__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
.source__tool{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
.source__basis{font-size:var(--type-meta-size);color:var(--text-secondary)}
.source__more{margin:var(--space-2) 0 0;font-size:var(--type-meta-size);color:var(--text-muted)}
.findings{list-style:none;margin:var(--space-2) 0 0;padding:0}
.finding{position:relative;display:flex;flex-direction:column;gap:var(--space-1);
padding:var(--space-2) 0 var(--space-2) var(--space-3);border-bottom:1px solid var(--border-subtle)}
.finding__accent{position:absolute;left:0;top:var(--space-2);bottom:var(--space-2);width:3px;
background:var(--sev-unknown)}
.finding__accent--critical{background:var(--sev-critical)}
.finding__accent--high{background:var(--sev-high)}
.finding__accent--medium{background:var(--sev-medium)}
.finding__accent--low{background:var(--sev-low)}
.finding__accent--info{background:var(--sev-info)}
.finding__title{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:var(--lh-ui)}
.finding__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
.finding__asset{font-size:var(--type-meta-size);color:var(--text-secondary)}
.badge{display:inline-block;padding:1px var(--space-2);border-radius:var(--radius-xs);
font-size:var(--type-badge-size);font-weight:var(--fw-semibold);letter-spacing:var(--ls-caps);
text-transform:uppercase;color:var(--sev-on)}
.badge--critical{background:var(--sev-critical)}
.badge--high{background:var(--sev-high)}
.badge--medium{background:var(--sev-medium)}
.badge--low{background:var(--sev-low)}
.badge--info{background:var(--sev-info)}
.badge--neutral{background:transparent;color:var(--text-secondary);border:1px solid var(--border-strong);
text-transform:none;letter-spacing:0;font-variant-numeric:tabular-nums}
.kpi{flex:1 1 150px;min-width:140px;display:flex;flex-direction:column;gap:var(--space-1)}
.kpi__figure{font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1;
font-variant-numeric:tabular-nums}
.kpi__label{font-size:var(--type-meta-size);color:var(--text-secondary)}
.verdicts{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-6);margin:var(--space-4) 0 0}
.verdicts__row{display:flex;align-items:baseline;gap:var(--space-2)}
.verdicts dt{font-size:var(--type-meta-size);color:var(--text-secondary)}
.verdicts dd{margin:0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);
font-variant-numeric:tabular-nums}
.table{width:100%;margin-top:var(--space-4);border-collapse:collapse;
font-size:var(--type-table-body-size)}
.table caption{margin-bottom:var(--space-2);text-align:left;font-size:var(--type-label-size);
font-weight:var(--fw-semibold);color:var(--text-secondary)}
.table th,.table td{padding:var(--space-2) var(--space-3);text-align:left;
border-bottom:1px solid var(--border-subtle)}
.table th{font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
.table .num{text-align:right;font-variant-numeric:tabular-nums}
@media print{
:root{--surface-app:#FFFFFF;--surface-panel:#FFFFFF;--tile-surface:#FFFFFF}
body{padding:0}
.segment{break-inside:avoid;margin-top:var(--space-6)}
.tile{break-inside:avoid;border:1px solid var(--border-subtle)}
.finding{break-inside:avoid}
}
@media (max-width:720px){
h1{font-size:var(--type-h2-size)}
.spine__item{grid-template-columns:1fr 60px}
.spine__meter{grid-column:1 / -1}
}
</style>
</head>
<body>
${head}
${posture}
${priorities}
${remediationSection}
${coverageSection}
${sources}
</body>
</html>`;
}

View File

@ -0,0 +1,224 @@
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
import type { FindingRecord } from '../domain/finding';
import type { Review } from '../domain/review';
import { toolIdForRecord } from '../importers/status-normalization';
import { recordResultSheets } from '../features/linked-results/record-results';
import { resultSectionsFor, resultStatusFieldFor } from '../features/linked-results/result-message';
import { isFileReference, resultReferenceValues } from '../profiles/purple-knight/result-linker';
import { formatPolicyComparisonValue } from '../profiles/group-policy/presentation';
import { policySourceGroups, policySourceValueLines } from '../profiles/group-policy/source-evidence';
import { parseMarkdownBlocks, type InlineToken } from '../shared/markdown';
import { rowsToSpreadsheetSafeCsv } from './csv-export';
import bundledInter from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?inline';
export const WORK_VIEW_EXPORT_VERSION = '1.0.0';
export const WORK_VIEW_COLUMNS = ['Soll-Zustand', 'Abweichungen'] as const;
export interface WorkExportBlock { label: string; value: string; rich?: boolean; lines?: Array<{ text: string; deviating?: true }> }
export interface WorkExportEntry {
id: string;
title: string;
status: string;
expected: WorkExportBlock[];
observed: WorkExportBlock[];
resultSheetIds: string[];
resultRowCount: number;
}
export interface WorkViewExport {
version: string;
generatedAt: string;
context: WorkExportBlock[];
entries: WorkExportEntry[];
}
export interface WorkViewExportInput {
dataset: ImportedDataset;
sheet: DatasetSheet;
/** Already filtered and sorted by Work; never select again in an exporter. */
records: readonly FindingRecord[];
generatedAt: string;
context?: WorkExportBlock[];
reviews?: Record<string, Review>;
titleColumn?: string;
}
const EXPECTED = /^(?:baseline value|expected(?: value| result)?|desired(?: value| state)?|recommended value|soll(?:-zustand|wert)?)$/i;
const SOLUTION = /^(?:remediation(?: guidance| recommendation| action)?|advised solution|solution|recommendation|resolution)$/i;
const AUDIT = /^(?:audit(?: procedure)?|verification|verify|test procedure)$/i;
const GUIDANCE = /^(?:description|rationale|impact|technical explanation|documentation|references?|notes)$/i;
const CONTEXT = /^(?:id|rule id|shortname|technical identity|scope|target|registry path|baseline|policy type|policy section|imported|evaluated|source: .+|source file|source sheet)$/i;
const TITLES = /^(?:title|name|finding|benchmark item|check title)$/i;
const NEUTRAL = new Set(['Not configured', 'No baseline rule', 'Not comparable', 'Review required']);
const GPO_SCOPE = 'Vergleich importierter GPO-Backups, kein Nachweis der wirksamen Endpoint-Konfiguration. Baseline-Varianten sind getrennte Empfehlungen; ihre Anwendbarkeit und Zielrolle vor einer Änderung bestätigen. Keine GPO-Priorität aus der Quellenreihenfolge ableiten.';
function sourceBlocks(record: FindingRecord): WorkExportBlock[] {
return policySourceGroups(record.policySourceEvidence ?? []).flatMap(group => [
{ label: group.label, value: [group.backupLabel, `Backup-Identität: ${group.id}`].filter(Boolean).join('\n') },
...group.observations.map(source => {
const lines = [
`Evidence: ${source.sourceReference}`, `Observation: ${source.id}`,
source.sequence === undefined ? '' : `Source sequence: ${source.sequence}`
].filter(Boolean).map(text => ({ text }));
const instructions = source.values.flatMap((value, index) => policySourceValueLines(value, source.deviationFields?.[index], source.deviationItems?.[index]));
return { label: 'GPO-Quellbeleg', value: [...lines, ...instructions].map(line => line.text).join('\n'), lines: [...lines, ...instructions] };
})
]);
}
function entryFor(input: WorkViewExportInput, record: FindingRecord): WorkExportEntry {
const tool = toolIdForRecord(record, input.sheet.toolId ?? input.dataset.profileId);
const gpo = tool === 'group-policy';
const statusField = resultStatusFieldFor(record, tool);
const status = statusField?.value ?? 'Unknown';
const title = record.fields.find(field => field.label === input.titleColumn)?.value || record.title;
const expected: WorkExportBlock[] = [];
const observed: WorkExportBlock[] = [{ label: 'Result Status', value: status }];
const context = [`Finding: ${title}`, `Record ID: ${record.id}`, `Source tool: ${tool ?? 'Unknown'}`,
record.findingOccurrenceId ? `Occurrence: ${record.findingOccurrenceId}` : '',
record.scanRunId ? `Scan run: ${record.scanRunId}` : '', record.importFileId ? `Import file: ${record.importFileId}` : ''];
const resultSheets = recordResultSheets(record, input.dataset.sheets, input.sheet.id, tool);
const sections = resultSectionsFor(record, tool);
const sectionValues = new Set(sections.map(section => section.value));
let hasExpected = false;
let hasSolution = false;
let hasAudit = false;
for (const field of record.fields) {
if (!field.value || TITLES.test(field.label) || field.index === statusField?.index) continue;
if (CONTEXT.test(field.label)) { context.push(`${field.label}: ${field.value}`); continue; }
if (sectionValues.has(field.value)) continue;
if (EXPECTED.test(field.label)) {
hasExpected = true;
expected.push({ label: 'Sollwert · ' + field.label, value: gpo ? formatPolicyComparisonValue(field.value) : field.value });
// The reader formatter is intentionally human-facing; retain the typed original
// too so empty collections, strings, zero and type codes remain unambiguous.
if (gpo) expected.push({ label: 'Sollwert · typisierter Originalwert', value: field.value });
} else if (SOLUTION.test(field.label)) {
hasSolution = true;
expected.push({ label: 'Behebung laut Quelle · ' + field.label, value: field.value, rich: true });
} else if (AUDIT.test(field.label)) {
hasAudit = true;
expected.push({ label: 'Prüfung laut Quelle · ' + field.label, value: field.value, rich: true });
} else if (GUIDANCE.test(field.label) && !gpo) {
// Maester retains its complete remediation within Description. Do not cut it off.
expected.push({ label: 'Regel und Anleitung laut Quelle · ' + field.label, value: field.value, rich: true });
} else if (gpo && field.label === 'Configured Value') {
if (!record.policySourceEvidence?.length) observed.push({ label: 'Konfiguration im Backup', value: formatPolicyComparisonValue(field.value) });
} else if (gpo && ['Sources', 'GPO conflict details', 'GPO duplicate details'].includes(field.label)) {
if (!resultSheets.length && !record.policySourceEvidence?.length) observed.push({ label: field.label, value: field.value });
} else if (/^(?:results?|verwandte daten)$/i.test(field.label) && resultSheets.length) {
// The matching sheets below carry these references in full, not only their filenames.
} else observed.push({ label: field.label, value: field.value, rich: !gpo && /^(?:description|details?|result message)$/i.test(field.label) });
}
expected.unshift({ label: 'Finding und Geltungsbereich', value: context.filter(Boolean).join('\n') });
if (gpo) expected.push({ label: 'Geltungsgrenze', value: GPO_SCOPE });
if (gpo && NEUTRAL.has(status)) {
expected.push({ label: 'Einordnung', value: status === 'Not configured'
? 'Score-neutral: Empfehlung im analysierten Backup-Set nicht konfiguriert. Keine automatische Behebungspflicht; vollständiges Baseline-Hardening ist ein eigener Auftrag.'
: status === 'No baseline rule' ? 'Score-neutral: Keine anwendbare Baseline-Regel vorhanden. Kein belegter Sollwert und keine daraus ableitbare Behebungspflicht.'
: 'Score-neutral: Vergleich nicht abschließend möglich. Zuerst Anwendbarkeit, technische Vergleichbarkeit und Diagnostik klären; keine Abweichung erfinden.' });
}
if (!hasExpected) expected.push({ label: 'Sollwert', value: 'Kein separater Sollwert hinterlegt. Maßgeblich sind die wiedergegebenen Quell-Anleitungen; es wird kein Wert aus dem Finding-Titel abgeleitet.' });
if (!hasSolution) expected.push({ label: 'Behebung', value: 'Keine separate Behebungsanleitung hinterlegt. Eingebettete Anleitung und Referenzen im Regeltext prüfen. Fehlen diese ebenfalls, ist eine fachliche Klärung erforderlich.' });
if (!hasAudit) expected.push({ label: 'Nachweis nach Änderung', value: gpo
? 'Nach bestätigter Anwendbarkeit und freigegebener Änderung neue GPO-Backups importieren und dieselbe technische Einstellung erneut auswerten. Wirksamkeit am Endpoint separat prüfen; der Backup-Vergleich beweist sie nicht.'
: 'Nach freigegebener Änderung dieselbe Regel im gleichen Scope erneut mit dem Quellwerkzeug ausführen. Nur ein vergleichbarer Rescan belegt die Behebung; Review-Status und Abwesenheit eines Findings reichen nicht.' });
observed.push(...sourceBlocks(record));
const referencedTables = [...new Set(resultReferenceValues(record).flatMap(value =>
[...value.matchAll(/source-table:(\d+)/g)].map(match => Number(match[1]))))];
const linkedTables = new Set(resultSheets.map(sheet => sheet.sourceTableIndex));
const missingTables = referencedTables.filter(index => !linkedTables.has(index));
if (missingTables.length) observed.push({ label: 'Results · fehlende Belege',
value: `Referenzierte Ergebnistabellen nicht geladen: ${missingTables.map(index => `source-table:${index}`).join(', ')}. Die verfügbaren Belege folgen; der Nachweis ist unvollständig.` });
for (const section of sections) observed.push({ label: 'Results · ' + section.title, value: section.value, rich: !/xml/i.test(section.title) });
let resultRowCount = 0;
for (const sheet of resultSheets) {
observed.push({ label: 'Results · ' + sheet.name, value: [`Sheet ID: ${sheet.id}`, sheet.sourceFile ? `Source: ${sheet.sourceFile}` : '', sheet.resultPreamble ?? '', `Zeilen: ${sheet.analysis.records.length}`].filter(Boolean).join('\n') });
for (const [index, row] of sheet.analysis.records.entries()) {
resultRowCount++;
// Vertical name/value records remain readable with arbitrarily wide source tables.
observed.push({ label: `${sheet.name} · Zeile ${index + 1}${row.highlighted ? ' · vom Quellbericht hervorgehoben' : ''}`,
value: sheet.analysis.headers.map(header => `${header}: ${row.fields.find(field => field.label === header)?.value ?? ''}`).join('\n') });
}
}
if (!resultSheets.length && !sections.length) {
const refs = resultReferenceValues(record).filter(isFileReference);
observed.push({ label: 'Results', value: refs.length
? `Referenzierte Results nicht geladen: ${[...new Set(refs)].join(', ')}. Diese Belege fehlen im Export; Quelldateien im selben Projekt laden.`
: 'Keine zusätzlichen Results hinterlegt. Dies ist kein Nachweis, dass keine Abweichung besteht.' });
}
const review = input.reviews?.[record.id];
if (review) {
const plan = [review.nextStep && `Next step: ${review.nextStep}`, review.prerequisites && `Prerequisites: ${review.prerequisites}`,
review.access && `Access: ${review.access}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean);
if (plan.length) expected.push({ label: 'Geplante Maßnahme · ungeprüfte Review-Angaben', value: plan.join('\n') });
observed.push({ label: 'Review · kein technischer Behebungsnachweis', value: [
`Status: ${review.status}`, `Priority: ${review.priority}`, review.comment && `Comment: ${review.comment}`,
review.evidence && `Evidence (Review): ${review.evidence}`, review.riskAcceptance ? `Risk acceptance: ${JSON.stringify(review.riskAcceptance)}` : ''
].filter(Boolean).join('\n') });
}
return { id: record.id, title, status, expected, observed, resultSheetIds: resultSheets.map(sheet => sheet.id), resultRowCount };
}
/** Click-time, platform-neutral, frozen projection shared by all three view formats. */
export function buildWorkViewExport(input: WorkViewExportInput): WorkViewExport {
return { version: WORK_VIEW_EXPORT_VERSION, generatedAt: input.generatedAt,
context: [{ label: 'Ansicht', value: `${input.dataset.fileName} · ${input.sheet.name}` },
{ label: 'Exportzeitpunkt', value: input.generatedAt }, { label: 'Treffer', value: String(input.records.length) },
...(input.context ?? []),
{ label: 'Umfang', value: 'Alle Treffer der aktuellen Filterung und Sortierung, einschließlich Results. Nicht passende oder noch nicht bewertete Treffer bleiben unverändert; keine Neuberechnung. Quelltexte bleiben in ihrer Originalsprache.' }],
entries: input.records.map(record => entryFor(input, record)) };
}
function inlineText(tokens: InlineToken[]): string {
return tokens.map(token => token.kind === 'strong' || token.kind === 'emphasis' ? inlineText(token.children)
: token.kind === 'link' ? `${inlineText(token.children)} (${token.href})` : token.text).join('');
}
function blockText(block: WorkExportBlock): string {
if (!block.rich) return block.value;
return parseMarkdownBlocks(block.value).map(part => part.kind === 'code' ? part.text
: part.kind === 'list' ? part.items.map((item, index) => `${part.ordered ? `${index + 1}.` : '•'} ${inlineText(item)}`).join('\n')
: part.kind === 'table' ? part.table.rows.map((row, index) => `Zeile ${index + 1}\n` + part.table.headers.map((header, column) => `${header}: ${row[column] ?? ''}`).join('\n')).join('\n\n')
: inlineText(part.content)).join('\n\n');
}
const blocksText = (blocks: WorkExportBlock[]) => blocks.map(block => `${block.label}\n${blockText(block)}`).join('\n\n');
const escapeHtml = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
const markdownCell = (value: string) => escapeHtml(value).replace(/[\\`*_{}\[\]()#!~]/g, '\\$&').replaceAll('|', '&#124;').replace(/\r\n?|\n/g, '<br>');
export function workViewCsvRows(report: WorkViewExport): string[][] {
// A CSV has no cell-size limit, but Excel silently loses oversized cells. Explicit
// continuation rows retain two columns and the same finding ID, never new findings.
const chunks = (text: string) => {
const result: string[] = [];
while (text.length > 30000) {
let end = text.lastIndexOf('\n', 30000);
if (end < 15000) end = 30000;
else end += 1;
if (/[\uD800-\uDBFF]/.test(text[end - 1])) end--;
result.push(text.slice(0, end)); text = text.slice(end);
}
result.push(text);
return result;
};
return [[...WORK_VIEW_COLUMNS], ...report.entries.flatMap(entry => {
const left = chunks(`${blocksText(report.context)}\n\n${blocksText(entry.expected)}`);
const right = chunks(blocksText(entry.observed));
const count = Math.max(left.length, right.length);
return Array.from({ length: count }, (_, index) => {
const prefix = count > 1 ? `Finding ID: ${entry.id}\nTeil ${index + 1}/${count} · Fortsetzungszeile desselben Findings\n\n` : '';
return [prefix + (left[index] ?? ''), prefix + (right[index] ?? '')];
});
})];
}
export function renderWorkViewCsv(report: WorkViewExport, delimiter = ';'): string {
return '\ufeff' + rowsToSpreadsheetSafeCsv(workViewCsvRows(report), delimiter);
}
export function renderWorkViewMarkdown(report: WorkViewExport): string {
return `# ASSESSOR · Aktuelle Work-Ansicht\n\n${markdownCell(blocksText(report.context))}\n\n| ${WORK_VIEW_COLUMNS.join(' | ')} |\n| --- | --- |\n`
+ report.entries.map(entry => `| ${markdownCell(blocksText(entry.expected))} | ${markdownCell(blocksText(entry.observed))} |`).join('\n') + '\n';
}
export function renderWorkViewHtml(report: WorkViewExport): string {
const blocks = (items: WorkExportBlock[]) => items.map(item => `<section class="fact${item.rich ? ' prose' : ''}"><h4>${escapeHtml(item.label)}</h4><div class="value">${item.lines ? item.lines.map(line => line.deviating ? `<span class="deviation-value">${escapeHtml(line.text)}</span>` : escapeHtml(line.text)).join('\n') : escapeHtml(blockText(item))}</div></section>`).join('');
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'"><title>ASSESSOR · Work-Ansicht</title><style>
*{box-sizing:border-box}body{margin:0;background:#f3f4f6;color:#192231;font-family:Inter,system-ui,sans-serif;font-size:16px;line-height:1.5;font-weight:400}main{max-width:1500px;margin:auto;padding:20px}h1{font-size:32px;font-weight:600;margin:0 0 20px}h2{font-size:20px;font-weight:600;overflow-wrap:anywhere}h4{font-size:16px;font-weight:600;margin:0 0 6px}.meta{font-size:12px}.finding{margin:28px 0;background:white;padding:20px;border-top:3px solid #adb2bb}.columns{width:100%;table-layout:fixed;border-collapse:collapse}.columns th{text-align:left;font-size:12px;font-weight:600;padding:12px;background:#e9ecf0}.columns td{vertical-align:top;width:50%;padding:16px;border:1px solid #d7dbe1}.fact{margin:0 0 20px;min-width:0}.value{white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal}.meta .fact{margin:0 0 8px}.finding-count{font-size:12px}footer{font-size:12px}@page{size:A4 landscape;margin:12mm}@media print{body{background:white}main{padding:0;max-width:none}.finding{padding:0;break-before:page}.finding:first-of-type{break-before:auto}h2,h4{break-after:avoid}.columns thead{display:table-header-group}.columns td{padding:10px}.fact{break-inside:auto}}@media(max-width:700px){.columns,.columns tbody,.columns tr,.columns td{display:block;width:100%}.columns thead{display:none}.columns td:before{display:block;font-weight:600;margin-bottom:16px}.columns td:first-child:before{content:'Soll-Zustand'}.columns td:last-child:before{content:'Abweichungen'}}
.deviation-value{background:#f5dce0;border-left:2px solid #bf3a50;padding:0 4px}
@font-face{font-family:Inter;src:url('${bundledInter}') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}h1{font-size:56px;overflow-wrap:anywhere}.columns .value{font-size:14px}.columns .prose .value{font-size:16px}.meta h4{font-size:12px}
</style></head><body><main><h1>ASSESSOR · Aktuelle Work-Ansicht</h1><div class="meta">${blocks(report.context)}</div>${report.entries.map((entry, index) => `<article class="finding"><div class="finding-count">${index + 1} / ${report.entries.length} · ${escapeHtml(entry.status)}</div><h2>${escapeHtml(entry.title)}</h2><table class="columns"><thead><tr><th scope="col">Soll-Zustand</th><th scope="col">Abweichungen</th></tr></thead><tbody><tr><td>${blocks(entry.expected)}</td><td>${blocks(entry.observed)}</td></tr></tbody></table></article>`).join('')}<footer>Exportvertrag ${escapeHtml(report.version)} · Statischer Snapshot. Review-Angaben sind kein technischer Behebungsnachweis.</footer></main></body></html>`;
}

View File

@ -1,51 +1,43 @@
import { useMemo } from 'react';
import { useState } from 'react';
import { useApp } from '../../app/AppProvider';
import { objectsToCsv } from '../../exports/csv-export';
import { buildDashboardHtml } from '../../exports/dashboard-export';
import { Dialog } from '../../components/Dialog/Dialog';
import { buildWorkViewExport, renderWorkViewCsv, renderWorkViewHtml, renderWorkViewMarkdown } from '../../exports/work-view-export';
import { downloadText } from '../../exports/text-download';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { defaultReview } from '../../domain/review';
import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { selectWorkFacets } from '../search-filter/work-facets';
export function ActionlistControls() {
const { dataset, reviews } = useApp();
const hasRows = useMemo(() => Object.values(reviews).some(review => review.include)
&& Boolean(dataset?.sheets.some(sheet => sheet.analysis.records.some(record => reviews[record.id]?.include))), [dataset, reviews]);
const exportActionList = (format: 'csv' | 'md' | 'html') => {
if (!dataset || !hasRows) return;
const rows = dataset.sheets.flatMap(sheet => sheet.analysis.records
.filter(record => reviews[record.id]?.include)
.map(record => ({ record, sheetName: sheet.name, review: { ...defaultReview(), ...reviews[record.id] } })));
const exportRows = rows.map(({ record, sheetName, review }) => ({
Sheet: sheetName,
Record: record.index + 1,
Finding: record.title,
Status: review.status,
Priority: review.priority,
Owner: review.owner,
DueDate: review.due,
Prerequisites: review.prerequisites,
AccessRights: review.access,
NextStep: review.nextStep,
Evidence: review.evidence,
Comment: review.comment
}));
const name = `${dataset.fileName.replace(/\.[^.]+$/, '')}-actionlist`;
if (format === 'html') {
downloadText(browserDownloadAdapter, `${name}.html`, buildDashboardHtml(dataset.fileName, rows), 'text/html;charset=utf-8');
} else if (format === 'md') {
const markdown = exportRows.map((row, index) => `## ${index + 1}. ${row.Finding}\n\n- Status: ${row.Status}\n- Priority: ${row.Priority}\n- Owner: ${row.Owner || '-'}\n- Due Date: ${row.DueDate || '-'}\n- Next Step: ${row.NextStep || '-'}\n\n${row.Comment || ''}`).join('\n\n---\n\n');
downloadText(browserDownloadAdapter, `${name}.md`, markdown, 'text/markdown;charset=utf-8');
} else {
downloadText(browserDownloadAdapter, `${name}.csv`, `\ufeff${objectsToCsv(exportRows)}`, 'text/csv;charset=utf-8');
}
const app = useApp();
const records = useFilteredRecords();
const [failed, setFailed] = useState(false);
const exportView = (format: 'csv' | 'md' | 'html') => {
if (!app.dataset || !app.activeSheet || !records.length) return;
try {
const facets = selectWorkFacets(app.activeSheet, app);
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
const facet = facets.find(item => item.id === id);
return `${facet?.label ?? id}: ${filter.values?.map(value => facet?.values.find(item => item.value === value)?.label ?? value).join(', ') ?? ''}${filter.minimum ? ` ≥ ${filter.minimum}` : ''}${filter.maximum ? ` ≤ ${filter.maximum}` : ''}`;
});
const report = buildWorkViewExport({ dataset: app.dataset, sheet: app.activeSheet, records,
generatedAt: new Date().toISOString(), reviews: app.reviews, titleColumn: app.titleColumn,
context: [
{ label: 'Scope', value: [`Project: ${app.projectId}`, `Assessments: ${app.activeAssessmentIds?.join(', ') ?? 'geladener Workspace'}`, `Scan runs: ${app.activeScanRunIds?.join(', ') ?? 'geladener Workspace'}`].join('\n') },
{ label: 'Filter', value: [`Result Status: ${statusLabel}`, ...filters, `Suche: ${app.search || '—'}`, `My Workspace: ${app.actionOnly ? 'Ja' : 'Nein'}`, `Drilldown: ${app.pathFindingIds.length ? `${app.pathFindingIds.length} Record IDs` : 'Nein'}`].join('\n') },
{ label: 'Sortierung', value: `${app.sortColumn} · ${app.sortDirection}` }
] });
const text = format === 'html' ? renderWorkViewHtml(report) : format === 'md' ? renderWorkViewMarkdown(report)
: renderWorkViewCsv(report, app.delimiter === 'auto' ? ';' : app.delimiter);
const stem = app.dataset.fileName.replace(/\.[^.]+$/, '').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_');
downloadText(browserDownloadAdapter, `${stem}-work-view.${format}`, text,
format === 'html' ? 'text/html;charset=utf-8' : format === 'md' ? 'text/markdown;charset=utf-8' : 'text/csv;charset=utf-8');
setFailed(false);
} catch { setFailed(true); }
};
return (
<div className="header-export-row" aria-label="Export action list">
<button className="btn export-btn" type="button" disabled={!hasRows} onClick={() => exportActionList('csv')}>CSV</button>
<button className="btn export-btn" type="button" disabled={!hasRows} onClick={() => exportActionList('md')}>MD</button>
<button className="btn export-btn" type="button" disabled={!hasRows} onClick={() => exportActionList('html')}>HTML</button>
</div>
);
return <div className="header-export-row" aria-label="Export current view including Results">
{(['csv', 'md', 'html'] as const).map(format => <button key={format} className="btn export-btn" type="button" disabled={!records.length}
title={`Export current view including Results (${records.length} records)`} onClick={() => exportView(format)}>{format.toUpperCase()}</button>)}
{failed && <Dialog title="Export unavailable" onClose={() => setFailed(false)}><p>The current view could not be exported completely. No partial file was downloaded.</p></Dialog>}
</div>;
}

View File

@ -1,7 +1,10 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { OperatingSystemInventory } from './OperatingSystemInventory';
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon';
import type { ImportedDataset } from '../../domain/dataset';
@ -11,11 +14,17 @@ import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { compareSecurityScores, weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring';
import { dashboardSecuritySources } from './dashboard-security-score';
import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains';
import { ScoringMethodology } from './ScoringMethodology';
import { RiskRegister } from './RiskRegister';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet, type DynamicFacet, type DynamicFacetFilter, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state';
export { dashboardResultState } from './dashboard-result-state';
const assessorApi = new AssessorApiClient();
export { dashboardDomainAssignment, dashboardDomainRecords } from './security-domains';
interface DashboardPanelProps {
onOpenWork(): void;
@ -58,11 +67,10 @@ function DashboardStatusMetric({ label, value, percentage, tone, large = false,
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} />
</svg>
<span><span className={`dashboard-donut-primary${roundedPercentage >= 100 ? ' is-compact' : ''}`}><strong>{roundedPercentage}</strong><small>%</small></span></span>
<span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{roundedPercentage}%</small></span></span>
</div>
<div className="dashboard-status-copy">
<span>{label}</span>
{!large && <strong>{value.toLocaleString('en-US')}</strong>}
{helper && <small>{helper}</small>}
</div>
</>;
@ -377,7 +385,7 @@ function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profi
/>;
})}
</svg>
<span><span className="dashboard-donut-primary is-compact"><strong>100</strong><small>%</small></span></span>
<span><span className="dashboard-donut-primary"><strong>{severityTotal.toLocaleString('en-US')}</strong><small>100%</small></span></span>
</div>
<ul className="dashboard-risk-legend">
{severities.map(severity => (
@ -532,6 +540,7 @@ export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): Das
* fehlende Angabe wird nie als Null dargestellt.
*/
export interface DashboardEnvironmentDetail {
operatingSystems: OperatingSystemInventoryGroup[];
directory: DashboardFact[];
inventory: DashboardFact[];
passwordPolicy: DashboardFact[];
@ -608,14 +617,12 @@ export function dashboardMaesterTenantCount(snapshot?: EnvironmentSnapshot): num
}
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
const operatingSystems = [...new Set(snapshot?.observations
.filter(observation => observation.property === 'operating_system.name' && observation.state === 'observed')
.map(displayValue) ?? [])];
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
const categoryScores = (snapshot?.observations ?? [])
.filter(observation => observation.property.startsWith('purple_knight.ad.category_score.'))
.map(observation => ({ label: observation.label, value: numberFact(optionalObservationNumber(snapshot, observation.property)) }));
return {
operatingSystems: operatingSystemInventory(snapshot),
directory: [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
@ -633,7 +640,6 @@ export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): Dash
{ label: 'Password never expires', value: numberFact(userTable(/^nb pwd never expire$/i)) },
{ label: 'Computer objects', value: numberFact(dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i)) },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
{ label: 'Operating systems', value: operatingSystems.length ? operatingSystems.join(', ') : UNKNOWN_FACT },
{ label: 'Admin groups', value: numberFact(environmentTableRowCount(snapshot, 'sectionAdminGroups', /^admin groups list$/i)) },
{ label: 'Trusts', value: numberFact(environmentTableRowCount(snapshot, 'sectionTrustsdetails', /^trusts list$/i)) },
{ label: 'Assessed host', value: observationFact(snapshot, 'host.name') }
@ -679,6 +685,26 @@ export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): Dash
};
}
/**
* Kompakter, quellengestuetzter Verzeichniskontext fuer die erste Leselinie.
* Die Werte verwenden dieselben normalisierten Beobachtungen wie Environment
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
*/
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
return [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected') }
];
}
interface DashboardMetricTileProps {
label: string;
value: string;
@ -696,8 +722,37 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false,
: <article className={className}>{content}</article>;
}
function DashboardFactList({ items }: { items: Array<{ label: string; value: string }> }) {
return <dl className="client-info-facts">{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>;
/**
* Faktgruppe der Umgebungsdetails. Rein visuell: eine Gruppenregel und darunter
* ein flieszendes Raster gleich breiter Faktzellen. Ohne nebeneinanderstehende
* Karten kann keine hohe Gruppe ihre Nachbarn in die Hoehe strecken.
*
* Meldet die Quelle in einer Gruppe keinen einzigen Wert, bleibt sie als eine
* Zeile mit ausdruecklicher Aussage stehen und bleibt aufklappbar. Es wird kein
* Wert entfernt, zusammengefasst oder abgeleitet; Unbekanntes bleibt der
* Gedankenstrich der Datenschicht.
*/
function DashboardFactGroup({ title, items, glyph }: { title: string; items: DashboardFact[]; glyph: DashboardGlyphName }) {
const unreported = items.length > 0 && items.every(item => item.value === UNKNOWN_FACT);
const [expanded, setExpanded] = useState(false);
const listId = useId();
return <section className="client-env-group">
<h3 className="client-env-group-title">
<DashboardGlyph name={glyph} />
<span>{title}</span>
{unreported && <>
<span className="client-env-group-note">not reported by source</span>
<button className="client-env-group-toggle" type="button" aria-expanded={expanded} aria-controls={listId}
aria-label={`${expanded ? 'Hide' : 'Show'} ${title} fields`} title={`${expanded ? 'Hide' : 'Show'} fields`}
onClick={() => setExpanded(value => !value)}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</>}
</h3>
{(!unreported || expanded) && <dl className="client-env-facts" id={listId}>
{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>}
</section>;
}
interface DashboardDonutSegment {
@ -721,7 +776,7 @@ function DashboardDonut({ total, centerLabel, segments }: { total: number; cente
strokeDasharray={`${percentage} ${100 - percentage}`} strokeDashoffset={-segmentOffset} />;
})}
</svg>
<span><strong>{total.toLocaleString('en-US')}</strong><small>{centerLabel}</small></span>
<span><strong>{total.toLocaleString('en-US')}</strong><small>100%</small><em>{centerLabel}</em></span>
</div>
<ul className="client-info-legend">
{segments.map(segment => <li key={segment.label}>{segment.onClick
@ -788,7 +843,6 @@ export function DashboardDonutCard({ title, meta, initiallyExpanded = true, chil
</section>;
}
type DashboardResultState = 'passed' | 'deviation' | 'not-configured' | 'conflicts' | 'duplicates' | 'not-run' | 'not-applicable' | 'error' | 'unknown';
type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan';
interface DashboardSourceScore {
@ -797,6 +851,8 @@ interface DashboardSourceScore {
passed: number;
deviations: number;
assessed: number;
reviewRequired?: number;
reevaluationRequired?: number;
score?: number;
weight?: number;
rating?: SecuritySourceScore;
@ -814,141 +870,9 @@ interface DashboardDomainScore {
duplicates: number;
assessed: number;
records: FindingRecord[];
reviewRecords: FindingRecord[];
}
const dashboardDomainCatalog: Array<{ id: string; label: string; glyph: DashboardGlyphName; pattern: RegExp }> = [
{ id: 'identity', label: 'Identity & Account Hygiene', glyph: 'user', pattern: /account|identity|user|password|stale|inactive|honey\s*pot/i },
{ id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i },
{ id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass/i },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema/i },
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|firewall|attack\s*surface|\basr\b|defender|operating\s*system/i },
{ id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i },
{ id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i },
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i }
];
/**
* Auswertungsergebnisse je Datensatz.
*
* Ergebniszustand, Schweregrad und Klassifikationstext haengen nur am
* Datensatz und am aktiven Profil, nicht am Zeitpunkt der Anzeige. Ein
* Mandantenblatt behaelt seine Datensatzobjekte, solange es geladen ist -
* deshalb bleibt das Ergebnis an ihnen haengen und ein erneuter Wechsel in die
* Client Info rechnet nicht dieselben mehreren tausend Datensaetze noch einmal
* durch. Der Speicher wird mit dem Blatt freigegeben.
*/
const recordDomainEvidenceCache = new WeakMap<FindingRecord, { text: string; explicitGpo: boolean }>();
const recordSeverityCache = new WeakMap<FindingRecord, string>();
const recordStateCache = new WeakMap<FindingRecord, { profile: ImportProfile; state: DashboardResultState }>();
function dashboardRecordDomainEvidence(record: FindingRecord): { text: string; explicitGpo: boolean } {
let cached = recordDomainEvidenceCache.get(record);
if (cached === undefined) {
const fields = record.fields;
cached = {
text: [record.title, ...fields
.filter(field => /^(?:title|name|finding|description|category|subcategory|sub-category|categories|control area|policy section|policy type)$/i.test(field.label.trim()))
.map(field => field.value)].join(' '),
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim()))
};
recordDomainEvidenceCache.set(record, cached);
}
return cached;
}
/**
* Zuordnung aller Datensaetze zu den Security Domains in einem Durchlauf.
*
* Der Klassifikationstext eines Datensatzes wird dabei genau einmal gebaut.
* Je Domain einzeln zu filtern hiess, ihn bei acht Domains achtmal neu
* zusammenzusetzen - bei mehreren tausend Datensaetzen der teuerste Teil der
* Ansicht.
*/
export function dashboardDomainAssignment(records: FindingRecord[]): Map<string, FindingRecord[]> {
const buckets = new Map([...dashboardDomainCatalog.map(domain => [domain.id, [] as FindingRecord[]] as const), ['unclassified', [] as FindingRecord[]] as const]);
records.forEach(record => {
const evidence = dashboardRecordDomainEvidence(record);
// A dashboard distribution must reconcile to the total. GPOs get their
// explicit source category first; every other record goes to the first
// matching primary domain, or to the transparent residual bucket.
const domainId = evidence.explicitGpo ? 'gpo' : dashboardDomainCatalog.find(domain => domain.pattern.test(evidence.text))?.id;
buckets.get(domainId ?? 'unclassified')!.push(record);
});
return buckets;
}
export function dashboardDomainRecords(records: FindingRecord[], domainId: string): FindingRecord[] {
return dashboardDomainAssignment(records).get(domainId) ?? [];
}
export function dashboardResultState(record: FindingRecord, profile: ImportProfile): DashboardResultState {
const cached = recordStateCache.get(record);
if (cached && cached.profile === profile) return cached.state;
const state = dashboardRecordStateOf(record, profile);
recordStateCache.set(record, { profile, state });
return state;
}
function dashboardMatchesStatus(record: FindingRecord, profile: ImportProfile, status: string): boolean {
const supported = status === 'deviation'
|| ['not-configured', 'conflicts', 'duplicates'].includes(status)
|| profile.presentation.statusFilters.some(filter => filter.value === status);
return supported && profile.matchesStatus(record, status);
}
function dashboardRecordStateOf(record: FindingRecord, profile: ImportProfile): DashboardResultState {
// Context states are deliberately disjoint from Deviations. In particular,
// GPO rows may contain words such as "failed" or "deviation" in their
// explanatory fields while their authoritative Status is Not configured,
// Conflicts or Duplicates.
if (dashboardMatchesStatus(record, profile, 'not-configured')) return 'not-configured';
if (dashboardMatchesStatus(record, profile, 'conflicts')) return 'conflicts';
if (dashboardMatchesStatus(record, profile, 'duplicates')) return 'duplicates';
if (dashboardMatchesStatus(record, profile, 'passed')) return 'passed';
if (dashboardMatchesStatus(record, profile, 'deviation')) return 'deviation';
if (dashboardMatchesStatus(record, profile, 'not-applicable')
|| dashboardMatchesStatus(record, profile, 'not-relevant')) return 'not-applicable';
if (dashboardMatchesStatus(record, profile, 'not-run')
|| dashboardMatchesStatus(record, profile, 'not-selected')
|| dashboardMatchesStatus(record, profile, 'canceled')
|| dashboardMatchesStatus(record, profile, 'skipped')
|| dashboardMatchesStatus(record, profile, 'manual')) return 'not-run';
if (dashboardMatchesStatus(record, profile, 'error')
|| dashboardMatchesStatus(record, profile, 'failed-to-run')) return 'error';
return 'unknown';
}
function dashboardScoreFromStates(states: DashboardResultState[]) {
let passed = 0;
let deviations = 0;
let notConfigured = 0;
let conflicts = 0;
let duplicates = 0;
let eligible = 0;
states.forEach(state => {
if (state === 'passed') passed += 1;
else if (state === 'deviation') deviations += 1;
else if (state === 'not-configured') notConfigured += 1;
else if (state === 'conflicts') conflicts += 1;
else if (state === 'duplicates') duplicates += 1;
if (state !== 'not-applicable') eligible += 1;
});
const assessed = passed + deviations;
return {
passed,
deviations,
notConfigured,
conflicts,
duplicates,
assessed,
eligible,
unassessed: Math.max(0, eligible - assessed),
score: assessed > 0 ? (passed / assessed) * 100 : undefined,
coverage: eligible > 0 ? Math.round((assessed / eligible) * 100) : undefined
};
}
export function dashboardOverallScore(sourceScores: Array<{ score?: number; weight?: number }>): number | undefined {
return weightedSecurityScore(sourceScores);
}
@ -957,20 +881,6 @@ function dashboardFieldValue(record: FindingRecord, pattern: RegExp): string | u
return record.fields.find(field => pattern.test(field.label.trim()))?.value;
}
function dashboardSeverity(record: FindingRecord): string {
let cached = recordSeverityCache.get(record);
if (cached === undefined) {
cached = dashboardFieldValue(record, /^(?:severity|risk|priority)$/i)?.trim().toLocaleLowerCase('en-US') ?? 'unknown';
recordSeverityCache.set(record, cached);
}
return cached;
}
function dashboardSourceName(record: FindingRecord): string {
const source = dashboardFieldValue(record, /^(?:source|quelle):\s*scan$/i);
return source ? dashboardScanSourceLabel(source) : 'Assessment';
}
function dashboardObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string): number | undefined {
const observation = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
const value = observation?.normalizedValue ?? observation?.originalValue;
@ -1069,7 +979,7 @@ function DashboardGlyph({ name }: { name: DashboardGlyphName }) {
* sodass die Linien nur bei genau einer Fenstergroesse an ihrem Knoten
* ankamen und sonst daneben endeten.
*/
function DashboardPostureConnectors({ nodeCount }: { nodeCount: number }) {
export function DashboardPostureConnectors({ nodeCount }: { nodeCount: number }) {
// Gemessen wird ab dem eigenen Element: der Ref des umgebenden Abschnitts ist
// waehrend des Layouteffekts eines Kindes noch nicht gesetzt.
const svgRef = useRef<SVGSVGElement | null>(null);
@ -1129,33 +1039,48 @@ function DashboardPostureConnectors({ nodeCount }: { nodeCount: number }) {
</svg>;
}
function DashboardOverallRing({ score }: { score?: number }) {
function dashboardScoreTone(score?: number): 'unknown' | 'critical' | 'medium' | 'passed' {
return score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
}
export function DashboardOverallRing({ score, onClick }: { score?: number; onClick?(): void }) {
const value = score ?? 0;
const tone = score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
return <div className={`client-overall-ring tone-${tone}`} role="img" aria-label={`Overall Score: ${score === undefined ? 'No data' : Math.round(score)}`}>
const tone = dashboardScoreTone(score);
const label = `Overall Score: ${score === undefined ? 'No data' : Math.round(score)}`;
const content = <>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="client-overall-ring-track" cx="60" cy="60" r="48" pathLength="100" />
{score !== undefined && <circle className="client-overall-ring-value" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${value} ${100 - value}`} />}
</svg>
<span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : Math.round(score)}</strong>{score !== undefined && <small>/100</small>}</span><em>ASSESSOR</em></span>
</div>;
</>;
return onClick
? <button className={`client-overall-ring tone-${tone}`} type="button" aria-label={`Open remediation overview in Work. ${label}`} onClick={onClick}>{content}</button>
: <div className={`client-overall-ring tone-${tone}`} role="img" aria-label={label}>{content}</div>;
}
function DashboardTopFact({ value, label, glyph }: { value?: number | string; label: string; glyph: DashboardGlyphName }) {
return <article className="client-overview-fact"><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></article>;
return <article className="client-data-fact"><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></article>;
}
function DashboardDomainRow({ domain, onClick }: { domain: DashboardDomainScore; onClick?(): void }) {
function dashboardDomainAmount(domain: DashboardDomainScore): number {
return domain.assessed === 0 && domain.reviewRecords.length > 0 ? domain.reviewRecords.length : domain.deviations;
}
function DashboardDomainRow({ domain, distributionTotal, onClick }: { domain: DashboardDomainScore; distributionTotal: number; onClick?(): void }) {
// Der Knoten traegt die Bestehensquote der Domaene. Ohne
// bewertete Ergebnisse bleibt das Sinnbild stehen - eine Null waere hier
// eine Aussage, die die Datenlage nicht deckt.
const tone = domain.score === undefined ? 'unknown' : domain.score < 50 ? 'critical' : domain.score < 75 ? 'medium' : 'passed';
const reviewOnly = domain.assessed === 0 && domain.reviewRecords.length > 0;
const amount = dashboardDomainAmount(domain);
const content = <>
<span className="client-domain-amount-bar" aria-hidden="true"><i style={shareStyle(share(amount, distributionTotal))} /></span>
<span className={`client-domain-score tone-${tone}`} title={domain.score === undefined ? 'Not assessed' : `Pass rate ${Math.round(domain.score)}%`}>
{domain.score === undefined ? <DashboardGlyph name={domain.glyph}/> : <em>{Math.round(domain.score)}</em>}
</span>
<span className="client-domain-copy"><strong>{domain.label}</strong><small>{domain.passed.toLocaleString('en-US')} passed · {domain.notConfigured.toLocaleString('en-US')} not configured · {domain.duplicates.toLocaleString('en-US')} duplicates · {domain.conflicts.toLocaleString('en-US')} conflicts</small></span>
<b>{domain.deviations.toLocaleString('en-US')}<small>Deviations</small></b>
<span className="client-domain-copy"><strong>{domain.label}</strong></span>
<b title={reviewOnly ? 'Review required' : 'Deviations'}>{amount.toLocaleString('en-US')}</b>
</>;
return <li>{onClick ? <button type="button" onClick={onClick}>{content}</button> : <div>{content}</div>}</li>;
}
@ -1175,11 +1100,11 @@ function nativeMeta(parts: Array<string | undefined>): string {
}
/**
* Jede Quellkachel nennt zuerst ihren vergleichbaren ASSESSOR-Score. Die
* Jede Quellenzeile nennt zuerst ihren vergleichbaren ASSESSOR-Score. Die
* werkzeugeigene Zahl steht daneben und immer mit ihrem Namen - eine
* PingCastle-Risikozahl (hoch ist schlecht) darf nie als Score gelesen werden.
*/
function DashboardSourceTile({ source, native, onClick }: { source: DashboardSourceScore; native: DashboardNativeMetrics; onClick?(): void }) {
function DashboardSourceListItem({ source, native, onClick }: { source: DashboardSourceScore; native: DashboardNativeMetrics; onClick?(): void }) {
const normalized = source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
const kind = normalized.includes('pingcastle') ? 'pingcastle' : normalized.includes('purple') ? 'purple' : normalized.includes('maester') ? 'maester' : normalized.includes('cis') ? 'cis' : normalized.includes('gpo') ? 'gpo' : 'other';
const score = source.score;
@ -1187,7 +1112,9 @@ function DashboardSourceTile({ source, native, onClick }: { source: DashboardSou
const assessorScore = { value: score === undefined ? '—' : Math.round(score), unit: '/100 ASSESSOR' };
const nativeScans = source.rating?.scans.filter(scan => scan.nativeValue !== undefined) ?? [];
const exactNative = nativeScans.length === 1 ? nativeScans[0].nativeValue : undefined;
const scoreMethod = source.rating?.methods.length === 1
const scoreMethod = source.reevaluationRequired && !source.assessed ? 'Re-evaluation required'
: source.reviewRequired && !source.assessed ? 'Review required'
: source.rating?.methods.length === 1
? ({ native: 'Native score', 'inverted-native': '100 − native risk', 'pass-rate': 'Pass rate', unavailable: 'Not scoreable' })[source.rating.methods[0]]
: source.rating ? 'Combined scan methods' : 'Pass rate';
const domains = native.domainCount === undefined ? undefined : `${native.domainCount} ${native.domainCount === 1 ? 'domain' : 'domains'}`;
@ -1207,10 +1134,15 @@ function DashboardSourceTile({ source, native, onClick }: { source: DashboardSou
`${source.assessed.toLocaleString('en-US')} controls`
]) }
: kind === 'gpo' ? { name: 'GPO Analysis', glyph: 'domain' as const, ...assessorScore,
meta: nativeMeta([`${source.deviations.toLocaleString('en-US')} baseline deviations`, `${native.gpoCount ?? '—'} GPOs`]) }
meta: nativeMeta([source.reevaluationRequired ? `${source.reevaluationRequired.toLocaleString('en-US')} saved comparisons`
: source.reviewRequired && !source.assessed ? `${source.reviewRequired.toLocaleString('en-US')} comparisons require review`
: `${source.deviations.toLocaleString('en-US')} baseline deviations`, `${native.gpoCount ?? '—'} GPOs`]) }
: { name: source.tool.label, glyph: 'policy' as const, ...assessorScore, meta: `${source.assessed.toLocaleString('en-US')} controls assessed` };
const content = <><DashboardGlyph name={presentation.glyph}/><span>{presentation.name}</span><strong>{presentation.value}<small>{presentation.unit}</small></strong><em>{scoreMethod} · {presentation.meta}{source.rating?.issues.length ? ' · Provisional' : ''}</em></>;
return onClick ? <button className={`client-source-${kind} tone-${tone}`} type="button" onClick={onClick}>{content}</button> : <article className={`client-source-${kind} tone-${tone}`}>{content}</article>;
return <li>{onClick
? <button className={`client-source-${kind} tone-${tone}`} type="button" onClick={onClick}>{content}</button>
: <div className={`client-source-${kind} tone-${tone}`}>{content}</div>}
</li>;
}
const NO_RECORDS: FindingRecord[] = [];
@ -1295,24 +1227,13 @@ export function dashboardAnalysis(
const severityFacet = countedFacets.find(facet => facet.severityScale);
const states = new Map<FindingRecord, DashboardResultState>();
const severities = new Map<FindingRecord, string>();
const priorityCandidates: FindingRecord[] = [];
let criticalDeviations = 0;
let highDeviations = 0;
records.forEach(record => {
const state = dashboardResultState(record, activeProfile);
const severity = dashboardSeverity(record);
states.set(record, state);
severities.set(record, severity);
if (state === 'deviation' && (severity === 'critical' || severity === 'high')) {
priorityCandidates.push(record);
if (severity === 'critical') criticalDeviations += 1;
else highDeviations += 1;
}
});
const domainBuckets = dashboardDomainAssignment(records);
const scoreOf = (subset: FindingRecord[]) =>
dashboardScoreFromStates(subset.map(record => states.get(record) ?? 'unknown'));
dashboardScoreFromStates(subset.map(record => states.get(record) ?? 'unknown'), subset);
const tools = dashboardRiskTools(sourceFacet?.values ?? []);
const sourceScores: DashboardSourceScore[] = dashboardSecuritySources(tools.map(tool => {
@ -1321,7 +1242,7 @@ export function dashboardAnalysis(
: NO_RECORDS;
return { tool, records: toolRecords, ...scoreOf(toolRecords) };
}).filter(source => source.records.length > 0), securityScoring, Boolean(activeSheet?.isProjectSheet && !sourceFilters));
const domains: DashboardDomainScore[] = dashboardDomainCatalog.map(domain => {
const domains: DashboardDomainScore[] = securityDomainCatalog.map(domain => {
const domainRecords = domainBuckets.get(domain.id) ?? NO_RECORDS;
const score = scoreOf(domainRecords);
return {
@ -1331,6 +1252,7 @@ export function dashboardAnalysis(
// The clickable population is exactly the number displayed. Passed
// and contextual GPO states remain visible only as secondary counts.
records: domainRecords.filter(record => states.get(record) === 'deviation'),
reviewRecords: domainRecords.filter(record => ['review-required', 'reevaluation-required'].includes(states.get(record) ?? '')),
...score
};
});
@ -1341,9 +1263,10 @@ export function dashboardAnalysis(
label: 'Unclassified',
glyph: 'scan',
records: unclassifiedRecords.filter(record => states.get(record) === 'deviation'),
reviewRecords: unclassifiedRecords.filter(record => ['review-required', 'reevaluation-required'].includes(states.get(record) ?? '')),
...unclassifiedScore
} : undefined;
const totalResult = dashboardScoreFromStates([...states.values()]);
const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records);
return {
total: records.length,
@ -1353,18 +1276,11 @@ export function dashboardAnalysis(
tools,
reports: sourceFacet?.values.length ?? 0,
statusCount: (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0,
highSeverity: highDeviations,
criticalSeverity: criticalDeviations,
sourceScores,
domains,
unclassifiedDomain,
totalResult,
overallScore: dashboardOverallScore(sourceScores),
lowestDomain: domains.flatMap(domain => domain.score === undefined ? [] : [domain.score]).sort((left, right) => left - right)[0],
priorityRecords: priorityCandidates
.sort((left, right) => Number(severities.get(right) === 'critical') - Number(severities.get(left) === 'critical'))
.slice(0, 3),
severityOf: (record: FindingRecord) => severities.get(record) ?? 'unknown'
overallScore: dashboardOverallScore(sourceScores)
};
}
}
@ -1377,7 +1293,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false);
const [restrictedOpen, setRestrictedOpen] = useState(false);
const overallScoreRef = useRef<HTMLElement | null>(null);
const environmentDetailRef = useRef<HTMLDetailsElement | null>(null);
const scoreComparison = useMemo(() => dataset?.securityScoring?.baseline
? compareSecurityScores(dataset.securityScoring.baseline, dataset.securityScoring.scans) : undefined, [dataset?.securityScoring]);
// Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der
@ -1388,14 +1308,30 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
[analysis]
);
useLayoutEffect(() => {
const syncEnvironmentTileHeight = () => {
const overallHeight = overallScoreRef.current?.getBoundingClientRect().height;
if (!overallHeight || !environmentDetailRef.current) return;
environmentDetailRef.current.style.setProperty('--client-environment-tile-height', `${overallHeight * 0.75}px`);
};
syncEnvironmentTileHeight();
window.addEventListener('resize', syncEnvironmentTileHeight);
const observer = overallScoreRef.current && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(syncEnvironmentTileHeight) : undefined;
if (overallScoreRef.current && observer) observer.observe(overallScoreRef.current);
return () => {
window.removeEventListener('resize', syncEnvironmentTileHeight);
observer?.disconnect();
};
}, [dataset, activeSheet]);
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, statusFacet, severityFacet, sourceFacet, tools, reports, statusCount,
sourceScores, domains, unclassifiedDomain, totalResult, overallScore, lowestDomain, priorityRecords,
highSeverity, criticalSeverity, severityOf
sourceScores, domains, unclassifiedDomain, totalResult, overallScore
} = analysis;
const deviations = totalResult.deviations;
const assessmentCoverage = totalResult.coverage;
@ -1419,7 +1355,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
gpoCount
};
const sourceOrder = ['pingcastle', 'purpleknight', 'maester', 'cis-cat', 'gpos'];
const orderedSourceScores = [...sourceScores].sort((left, right) => {
const orderedSourceScores = sourceScores.filter(source =>
source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '') !== 'tabulardata'
).sort((left, right) => {
const rank = (label: string) => {
const normalized = label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
const index = sourceOrder.findIndex(item => normalized.includes(item.replace(/[^a-z0-9]+/g, '')));
@ -1428,10 +1366,12 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
return rank(left.tool.label) - rank(right.tool.label) || left.tool.label.localeCompare(right.tool.label);
});
const displayedDomains = unclassifiedDomain?.deviations ? [...domains, unclassifiedDomain] : domains;
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore);
const jump = (apply: () => void) => {
const jump = (apply: () => void, preserveSource = true) => {
resetFilters();
if (sourceFilter && sourceFacet) setFacetFilter(sourceFacet.id, sourceFilter);
if (preserveSource && sourceFilter && sourceFacet) setFacetFilter(sourceFacet.id, sourceFilter);
app.setActionOnly(false);
setStatusFilter('deviation');
apply();
@ -1457,47 +1397,33 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
</header>
<section className="client-overview-facts" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" />
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" />
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" />
</section>
<div className="client-overview-main">
<section className="client-posture" aria-label="Security posture">
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
<DashboardPostureConnectors nodeCount={displayedDomains.length} />
<section className="client-overall" aria-labelledby="client-overall-heading">
<section ref={overallScoreRef} className="client-overall" aria-labelledby="client-overall-heading">
<h2 id="client-overall-heading">Overall Score</h2>
<DashboardOverallRing score={overallScore} />
<strong className={`client-overall-status tone-${overallScore === undefined ? 'unknown' : overallScore < 50 ? 'critical' : overallScore < 75 ? 'medium' : 'passed'}`}>
{overallScore === undefined ? 'Not assessed' : dataset.securityScoring?.historyUnavailable ? 'History unavailable' : scoreComparison?.scopeChanged ? 'Scope changed' : sourceScores.some(source => source.rating?.issues.length) ? 'Provisional' : overallScore < 50 ? 'Action Required' : overallScore < 75 ? 'Needs Attention' : 'Good'}
</strong>
<button className="client-overall-critical" type="button" disabled={criticalSeverity === 0 || !severityFacet} onClick={() => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: ['critical'] }))}>
<b>{criticalSeverity.toLocaleString('en-US')}</b><span>Critical Findings</span>
</button>
<DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} />
<div className="client-overall-coverage">
<span><strong>Assessment Coverage</strong><b>{assessmentCoverage === undefined ? '—' : `${assessmentCoverage}%`}</b></span>
<i><u style={shareStyle(assessmentCoverage ?? 0)} /></i>
<small>{totalResult.assessed.toLocaleString('en-US')} / {totalResult.eligible.toLocaleString('en-US')} eligible controls assessed</small>
</div>
<dl className="client-overall-secondary">
<div><dt>Unassessed</dt><dd>{totalResult.unassessed.toLocaleString('en-US')}</dd></div>
<div><dt>Lowest domain pass rate</dt><dd>{lowestDomain === undefined ? '—' : `${Math.round(lowestDomain)}%`}</dd></div>
</dl>
</section>
<section className="client-domains" aria-labelledby="client-domains-heading">
<header><h2 id="client-domains-heading">Security Domains</h2><span>Pass rates · {deviations.toLocaleString('en-US')} total deviations</span></header>
<ul>{displayedDomains.map(domain => <DashboardDomainRow key={domain.id} domain={domain} onClick={domain.records.length ? () => jump(() => { setPathFindingIds(domain.records.map(record => record.id)); }) : undefined} />)}</ul>
<header><h2 id="client-domains-heading">Security Domains</h2></header>
<ul>{displayedDomains.map(domain => <DashboardDomainRow key={domain.id} domain={domain} distributionTotal={domainDistributionTotal} onClick={domain.records.length
? () => jump(() => { setFacetFilter(SECURITY_DOMAIN_FACET_ID, { values: [domain.id] }); })
: domain.assessed === 0 && domain.reviewRecords.length ? () => jump(() => {
setStatusFilter('all');
setPathFindingIds(domain.reviewRecords.map(record => record.id));
}) : undefined} />)}</ul>
</section>
</section>
<aside className="client-priority" aria-labelledby="client-priority-heading">
<section className="client-priority-findings">
<header><h2 id="client-priority-heading">Priority Findings</h2><div><strong>{deviations.toLocaleString('en-US')}</strong><span>Deviations</span></div><div className="tone-critical"><strong>{criticalSeverity.toLocaleString('en-US')}</strong><span>Critical</span></div><div className="tone-medium"><strong>{highSeverity.toLocaleString('en-US')}</strong><span>High</span></div></header>
<header><h2 id="client-priority-heading">Data</h2><DonutMetricCard className="datahub-reference-knob client-priority-total" label="Deviations" value={deviations} percentage={share(deviations, total)} tone="deviation" onClick={() => jump(() => undefined)} /></header>
<div className="client-priority-context" aria-label="Additional result states">
{([
['passed', 'Passed', totalResult.passed],
@ -1506,50 +1432,80 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
['conflicts', 'Conflicts', totalResult.conflicts]
] as const).map(([status, label, count]) => <button type="button" key={status} disabled={count === 0} onClick={() => jump(() => setStatusFilter(status))}><strong>{count.toLocaleString('en-US')}</strong><span>{label}</span></button>)}
</div>
<ol>{priorityRecords.length ? priorityRecords.map(record => <li key={record.id} className={`tone-${severityOf(record)}`}><button type="button" onClick={() => jump(() => { setPathFindingIds([record.id]); })}><small>{severityOf(record)}</small><strong>{record.title}</strong><span>{dashboardSourceName(record)}</span></button></li>) : <li className="client-priority-empty">No critical or high deviations</li>}</ol>
<button className="client-overview-action" type="button" onClick={() => jump(() => setStatusFilter('deviation'))}>View findings →</button>
<section className="client-data-facts" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" />
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" />
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" />
</section>
</section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<h2 id="client-domain-information-heading">Domain Information</h2>
<dl>{domainInformation.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section>
<section className="client-assessment-gaps">
<h2>Assessment Gaps</h2><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Unassessed controls</span>
<dl><div><dt><DashboardGlyph name="clipboard"/>Manual review</dt><dd>{statusCount('manual').toLocaleString('en-US')}</dd></div><div><dt><DashboardGlyph name="folder"/>Missing evidence</dt><dd>{Math.max(0, totalResult.unassessed - statusCount('manual')).toLocaleString('en-US')}</dd></div>
{totalResult.reevaluationRequired > 0 && <div className="client-gpo-readiness" role="status">
<h3>GPO re-evaluation required</h3>
<p>{totalResult.reevaluationRequired.toLocaleString('en-US')} saved comparisons await re-evaluation. Open the GPO import in Data Hub and select Re-evaluate to calculate current results.</p>
<button className="btn" type="button" onClick={() => jump(() => setStatusFilter('all'))}>View saved results</button>
</div>}
<dl><div><dt><DashboardGlyph name="clipboard"/>Manual review</dt><dd>{(totalResult.reviewRequired + statusCount('manual')).toLocaleString('en-US')}</dd></div>
{totalResult.reevaluationRequired > 0 && <div><dt><DashboardGlyph name="clipboard"/>GPO re-evaluation</dt><dd>{totalResult.reevaluationRequired.toLocaleString('en-US')}</dd></div>}
<div><dt><DashboardGlyph name="folder"/>Missing evidence</dt><dd>{Math.max(0, totalResult.missingEvidence - statusCount('manual')).toLocaleString('en-US')}</dd></div>
{scoreComparison && <div><dt><DashboardGlyph name="clipboard"/>Awaiting revalidation</dt><dd>{dataset.securityScoring?.historyUnavailable ? '—' : scoreComparison.awaitingRevalidation.reduce((sum, item) => sum + item.occurrenceIds.length, 0).toLocaleString('en-US')}</dd></div>}
</dl>
<button className="client-overview-action" type="button" onClick={() => jump(() => setStatusFilter(statusCount('not-run') ? 'not-run' : 'all'))}>Review gaps →</button>
<button className="btn client-overview-action" type="button" onClick={() => jump(() => setStatusFilter(statusCount('not-run') ? 'not-run' : 'all'))}>Review gaps →</button>
</section>
</aside>
</div>
<section className="client-source-overview" aria-labelledby="client-source-overview-heading">
<header><h2 id="client-source-overview-heading">Source Overview</h2><button type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button></header>
<div>{orderedSourceScores.length ? orderedSourceScores.map(source => <DashboardSourceTile key={source.tool.id} source={source} native={nativeMetrics} onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues })) : undefined} />) : <p className="dashboard-empty">No source results available.</p>}</div>
</section>
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<section className="client-environment-detail" aria-labelledby="client-environment-detail-heading">
<header>
<details ref={environmentDetailRef} className="client-environment-detail" aria-labelledby="client-environment-detail-heading">
<summary>
<TriangleIcon direction="right" />
<h2 id="client-environment-detail-heading">Environment Detail</h2>
<span>{environment.failed ? 'Scanner context could not be loaded' : environment.snapshot
? `${environment.snapshot.observations.length.toLocaleString('en-US')} observations · ${environment.snapshot.sections.length.toLocaleString('en-US')} sections`
: 'Loading scanner context…'}</span>
</header>
<div className="client-info-card-grid">
<DashboardLargeCard title="Directory"><DashboardFactList items={environmentDetail.directory} /></DashboardLargeCard>
<DashboardLargeCard title="Inventory"><DashboardFactList items={environmentDetail.inventory} /></DashboardLargeCard>
<DashboardLargeCard title="Password policy"><DashboardFactList items={environmentDetail.passwordPolicy} /></DashboardLargeCard>
<DashboardLargeCard title="Certificates & PKI"><DashboardFactList items={environmentDetail.certificates} /></DashboardLargeCard>
<DashboardLargeCard title="Resilience"><DashboardFactList items={environmentDetail.resilience} /></DashboardLargeCard>
<DashboardLargeCard
title="Source metrics"
meta={environmentDetail.restricted.length ? `${environmentDetail.restricted.length} restricted sections` : undefined}
details={environmentDetail.restricted.length
? <p className="client-environment-restricted">Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}</p>
: undefined}
>
<DashboardFactList items={environmentDetail.toolContext} />
</DashboardLargeCard>
<span className="client-env-meta">
<span>{environment.failed ? 'Scanner context could not be loaded' : environment.snapshot
? `${environment.snapshot.observations.length.toLocaleString('en-US')} observations · ${environment.snapshot.sections.length.toLocaleString('en-US')} sections`
: 'Loading scanner context…'}</span>
{environmentDetail.restricted.length > 0 && <span>{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections</span>}
</span>
</summary>
{environmentDetail.restricted.length > 0 && <button className="btn client-env-restricted-toggle" type="button"
aria-expanded={restrictedOpen} aria-controls="client-environment-restricted"
onClick={() => setRestrictedOpen(value => !value)}>
{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections
<TriangleIcon direction={restrictedOpen ? 'down' : 'right'} />
</button>}
{restrictedOpen && environmentDetail.restricted.length > 0
&& <p className="client-environment-restricted" id="client-environment-restricted">
Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}
</p>}
<div className="client-environment-grid">
<section className="client-environment-sources" aria-labelledby="client-environment-sources-heading">
<header><h3 id="client-environment-sources-heading"><DashboardGlyph name="scan"/><span>Source Overview</span></h3><button className="btn" type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button></header>
{orderedSourceScores.length ? <ul>{orderedSourceScores.map(source => <DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics} onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => {
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
// A source drilldown opens its saved results, including GPO review,
// conflict and duplicate rows. Explicit deviation actions stay filtered.
if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
}) : undefined} />)}</ul> : <p className="dashboard-empty">No source results available.</p>}
</section>
<DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" />
<DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} />
</div>
</section>
</details>
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
</div>
);
}

View File

@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import type { OperatingSystemInventoryGroup } from './operating-system-inventory';
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
export function OperatingSystemInventory({ groups, icon }: { groups: OperatingSystemInventoryGroup[]; icon?: ReactNode }) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
return <section className="client-os-inventory" aria-label="Operating systems">
<header><h3>{icon}<span>Operating systems</span></h3>{inventories.length > 1 && <p>Source inventories are listed separately; overlapping counts are not added.</p>}</header>
<div className="client-os-groups">{groups.map(group => <section className="client-os-group" key={group.label}>
<h4><span>{group.label}</span><span aria-label={`${group.label} total`}>{quantity(group.total)}</span></h4>
{group.entries.length ? <table><thead><tr>
<th scope="col">Operating system</th>
{inventories.length > 1 && <th className="client-os-source" scope="col">Source</th>}
<th className="client-os-count" scope="col">Devices</th>
<th className="client-os-count" scope="col">Active</th>
<th className="client-os-count" scope="col">Inactive</th>
<th className="client-os-count" scope="col">Enabled</th>
<th className="client-os-count" scope="col">Disabled</th>
</tr></thead>
<tbody>{group.entries.map(entry => <tr key={entry.id}>
<td>{entry.name}</td>
{inventories.length > 1 && <td className="client-os-source">{entry.inventory === 'unquantified' ? 'Count not reported' : `Inventory ${inventories.indexOf(entry.inventory) + 1}`}</td>}
<td className="client-os-count">{quantity(entry.count)}</td>
<td className="client-os-count">{quantity(entry.active)}</td>
<td className="client-os-count">{quantity(entry.inactive)}</td>
<td className="client-os-count">{quantity(entry.enabled)}</td>
<td className="client-os-count">{quantity(entry.disabled)}</td>
</tr>)}</tbody></table>
: <p className="client-os-empty">No classified inventory available</p>}
</section>)}</div>
</section>;
}

View File

@ -0,0 +1,155 @@
import { useState } from 'react';
import type { ImportedDataset } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
import type { Review } from '../../domain/review';
import { TriangleIcon } from '../../components/TriangleIcon';
export interface RiskRegisterEntry {
id: string;
recordId: string;
occurrenceId: string;
title: string;
source: string;
state: ReturnType<typeof riskAcceptanceState>;
latest: RiskAcceptanceRevision;
revisions: RiskAcceptanceRevision[];
}
function sourceName(record: FindingRecord): string {
return record.fields.find(field => /^(?:source|quelle):\s*scan$/i.test(field.label.trim()))?.value.trim()
|| record.sourceSheet?.trim()
|| 'Assessment';
}
/** One register row per canonical occurrence, even when several sheets project it. */
export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<string, Review>, today = new Date().toISOString().slice(0, 10)): RiskRegisterEntry[] {
const entries = new Map<string, RiskRegisterEntry>();
dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
const value = reviews[record.id]?.riskAcceptance;
const latest = value?.revisions.at(-1);
if (!latest) return;
const occurrenceId = record.findingOccurrenceId ?? latest.targetOccurrenceId ?? record.id;
if (entries.has(occurrenceId)) return;
entries.set(occurrenceId, {
id: occurrenceId,
recordId: record.id,
occurrenceId,
title: record.title || 'Untitled finding',
source: sourceName(record),
state: riskAcceptanceState(value, occurrenceId, today),
latest,
revisions: value!.revisions
});
}));
return [...entries.values()].sort((left, right) => right.latest.recordedAt.localeCompare(left.latest.recordedAt)
|| left.title.localeCompare(right.title));
}
function displayDate(value: string): string {
if (!value) return '—';
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00Z`) : new Date(value);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', timeZone: 'UTC'
}).format(date);
}
function stateTone(state: RiskRegisterEntry['state']): string {
if (state === 'Accepted' || state === 'Scheduled') return 'current';
if (state === 'Review due' || state === 'Draft') return 'attention';
if (state === 'Expired' || state === 'Review required') return 'critical';
return 'neutral';
}
function DetailField({ label, value }: { label: string; value: string }) {
return <div><dt>{label}</dt><dd>{value.trim() || '—'}</dd></div>;
}
function DetailGroup({ title, details, fields }: {
title: string;
details: RiskAcceptanceDetails;
fields: Array<[keyof RiskAcceptanceDetails, string]>;
}) {
return <details className="risk-register-detail-group">
<summary><TriangleIcon direction="right" /><h3>{title}</h3></summary>
<dl>{fields.map(([key, label]) => <DetailField key={key} label={label} value={details[key]} />)}</dl>
</details>;
}
export function RiskRegister({ dataset, reviews, onOpenFinding }: {
dataset: ImportedDataset;
reviews: Record<string, Review>;
onOpenFinding?(recordId: string): void;
}) {
const entries = riskRegisterEntries(dataset, reviews);
const [requestedId, setRequestedId] = useState('');
const selected = entries.find(entry => entry.id === requestedId) ?? entries[0];
const currentCount = entries.filter(entry => ['Accepted', 'Scheduled', 'Review due', 'Review required'].includes(entry.state)).length;
return <section className="client-risk-register" aria-labelledby="client-risk-register-heading">
<div className="client-risk-register-layout">
<aside className="client-risk-register-navigation">
<header>
<div><h2 id="client-risk-register-heading">Accepted Risks</h2><span>Risk register</span></div>
<strong>{entries.length.toLocaleString('en-US')} recorded · {currentCount.toLocaleString('en-US')} current</strong>
</header>
<nav className="client-risk-register-list" aria-label="Accepted risks">
{entries.map((entry, index) => <button key={entry.id} type="button" className={`is-${stateTone(entry.state)}`} aria-pressed={entry.id === selected?.id} onClick={() => setRequestedId(entry.id)} onKeyDown={event => {
const targetIndex = event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1
: event.key === 'ArrowUp' ? Math.max(0, index - 1)
: event.key === 'ArrowDown' ? Math.min(entries.length - 1, index + 1) : index;
if (targetIndex === index || !['Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
setRequestedId(entries[targetIndex].id);
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button')[targetIndex]?.focus();
}}>
<span><strong>{entry.title}</strong><small>{entry.source}</small></span>
<em>{entry.state}</em>
<time dateTime={entry.latest.details.reviewOn || entry.latest.recordedAt}>Review {displayDate(entry.latest.details.reviewOn)}</time>
</button>)}
</nav>
</aside>
<main className="client-risk-register-information">
{!selected ? <p className="client-risk-register-empty">No risk acceptance has been recorded for this assessment.</p> : <article key={selected.id} className="client-risk-register-detail" aria-live="polite">
<header>
<div><strong>Risk Information</strong><span>{selected.source}</span></div>
<div className="risk-register-actions">{onOpenFinding && <button className="btn primary" type="button" onClick={() => onOpenFinding(selected.recordId)}>Open finding</button>}<strong className={`is-${stateTone(selected.state)}`}>{selected.state}</strong></div>
</header>
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2></section>
<dl className="risk-register-summary">
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
<DetailField label="Valid from" value={displayDate(selected.latest.details.validFrom)} />
<DetailField label="Expires" value={displayDate(selected.latest.details.expiresOn)} />
<DetailField label="Next review" value={displayDate(selected.latest.details.reviewOn)} />
<DetailField label="Tracking reference" value={selected.latest.details.trackingReference} />
</dl>
<div className="risk-register-detail-groups">
<DetailGroup title="Risk context" details={selected.latest.details} fields={[
['scope', 'Scope'], ['scenario', 'Risk scenario'], ['businessReason', 'Business rationale'],
['alternatives', 'Alternatives considered'], ['likelihood', 'Likelihood'], ['impact', 'Business impact'],
['residualAssessment', 'Residual risk assessment']
]} />
<DetailGroup title="Controls and monitoring" details={selected.latest.details} fields={[
['compensatingControls', 'Compensating controls'], ['controlOwner', 'Control owner'],
['controlEvidence', 'Control evidence'], ['monitoring', 'Monitoring'],
['reassessmentTriggers', 'Reassessment triggers'], ['exitPlan', 'Exit / remediation plan']
]} />
<DetailGroup title="Accountability and approval" details={selected.latest.details} fields={[
['riskOwner', 'Risk owner'], ['riskOwnerRole', 'Risk owner role / team'], ['riskOwnerContact', 'Risk owner contact'],
['approver', 'Approving authority'], ['approverRole', 'Approver role / authority'],
['approverContact', 'Approver contact'], ['approvalReference', 'Approval evidence'], ['approvedOn', 'Approved on']
]} />
<DetailGroup title="Governance and evidence" details={selected.latest.details} fields={[
['obligationsReview', 'Policy / regulatory review'], ['evidenceReferences', 'Additional evidence']
]} />
</div>
<details className="risk-register-history"><summary><TriangleIcon direction="right" /><h3>Decision history</h3><span>{selected.revisions.length} revisions</span></summary><ol>{[...selected.revisions].reverse().map(revision => <li key={revision.revision}>
<span><strong>{revision.action === 'accept' ? 'Accepted' : revision.action === 'revoke' ? 'Revoked' : 'Draft saved'}</strong><time dateTime={revision.recordedAt}>{displayDate(revision.recordedAt)}</time></span>
<p>{revision.changeReason}</p><small>Recorded by {revision.recordedBy} · Revision {revision.revision}</small>
</li>)}</ol></details>
</article>}
</main>
</div>
</section>;
}

View File

@ -0,0 +1,93 @@
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
export type DashboardResultState = 'passed' | 'deviation' | 'not-configured' | 'conflicts' | 'duplicates' | 'not-run' | 'not-applicable' | 'error' | 'unknown' | 'review-required' | 'reevaluation-required';
const recordStateCache = new WeakMap<FindingRecord, { profile: ImportProfile; state: DashboardResultState }>();
function fieldValue(record: FindingRecord, pattern: RegExp): string | undefined {
return record.fields.find(field => pattern.test(field.label.trim()))?.value;
}
function matchesStatus(record: FindingRecord, profile: ImportProfile, status: string): boolean {
const supported = status === 'deviation'
|| ['not-configured', 'conflicts', 'duplicates'].includes(status)
|| profile.presentation.statusFilters.some(filter => filter.value === status);
return supported && profile.matchesStatus(record, status);
}
function resultStateOf(record: FindingRecord, profile: ImportProfile): DashboardResultState {
if (fieldValue(record, /^status$/i)?.trim().toLowerCase() === 'review required') {
return fieldValue(record, /^evaluation readiness$/i)?.trim()
? 'reevaluation-required' : 'review-required';
}
// The authoritative verdict wins. Conflict and duplicate are independent
// GPO diagnostics and are counted from their dedicated fields below.
if (matchesStatus(record, profile, 'not-configured')) return 'not-configured';
if (matchesStatus(record, profile, 'passed')) return 'passed';
if (matchesStatus(record, profile, 'deviation')) return 'deviation';
if (matchesStatus(record, profile, 'conflicts')) return 'conflicts';
if (matchesStatus(record, profile, 'duplicates')) return 'duplicates';
if (matchesStatus(record, profile, 'not-applicable')
|| matchesStatus(record, profile, 'not-relevant')) return 'not-applicable';
if (matchesStatus(record, profile, 'not-run')
|| matchesStatus(record, profile, 'not-selected')
|| matchesStatus(record, profile, 'canceled')
|| matchesStatus(record, profile, 'skipped')
|| matchesStatus(record, profile, 'manual')) return 'not-run';
if (matchesStatus(record, profile, 'error')
|| matchesStatus(record, profile, 'failed-to-run')) return 'error';
return 'unknown';
}
export function dashboardResultState(record: FindingRecord, profile: ImportProfile): DashboardResultState {
const cached = recordStateCache.get(record);
if (cached && cached.profile === profile) return cached.state;
const state = resultStateOf(record, profile);
recordStateCache.set(record, { profile, state });
return state;
}
export function dashboardScoreFromStates(states: DashboardResultState[], records: FindingRecord[] = []) {
let passed = 0;
let deviations = 0;
let notConfigured = 0;
let conflicts = 0;
let duplicates = 0;
let reviewRequired = 0;
let reevaluationRequired = 0;
let missingEvidence = 0;
let eligible = 0;
states.forEach((state, index) => {
if (state === 'passed') passed += 1;
else if (state === 'deviation') deviations += 1;
else if (state === 'not-configured') notConfigured += 1;
else if (state === 'conflicts') conflicts += 1;
else if (state === 'duplicates') duplicates += 1;
else if (state === 'review-required') reviewRequired += 1;
else if (state === 'reevaluation-required') reevaluationRequired += 1;
else if (state === 'unknown' || state === 'not-run' || state === 'error') missingEvidence += 1;
if (state !== 'not-applicable') eligible += 1;
const record = records[index];
const diagnosticPresent = (value: string | undefined) => value !== undefined
&& !['', 'no', 'none', 'false', '0'].includes(value.trim().toLowerCase());
if (record && state !== 'conflicts' && diagnosticPresent(fieldValue(record, /^conflict$/i))) conflicts += 1;
if (record && state !== 'duplicates' && diagnosticPresent(fieldValue(record, /^duplicates$/i))) duplicates += 1;
});
const assessed = passed + deviations;
return {
passed,
deviations,
notConfigured,
conflicts,
duplicates,
reviewRequired,
reevaluationRequired,
missingEvidence,
assessed,
eligible,
unassessed: Math.max(0, eligible - assessed),
score: assessed > 0 ? (passed / assessed) * 100 : undefined,
coverage: eligible > 0 ? Math.round((assessed / eligible) * 100) : undefined
};
}

View File

@ -0,0 +1,98 @@
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
export type OperatingSystemGroup = 'Servers' | 'Clients' | 'Other / unclassified';
export interface OperatingSystemEntry {
id: string;
name: string;
count?: number;
active?: number;
inactive?: number;
enabled?: number;
disabled?: number;
inventory: string;
}
export interface OperatingSystemInventoryGroup { label: OperatingSystemGroup; entries: OperatingSystemEntry[]; total?: number }
function value(observation: EnvironmentObservation): string {
const raw = observation.normalizedValue ?? observation.originalValue;
return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : '';
}
function count(value: string): number | undefined {
if (!/^(?:\d+|\d{1,3}(?:,\d{3})+|\d{1,3}(?:[ \u00a0\u202f]\d{3})+)$/.test(value)) return undefined;
const number = Number(value.replace(/[, \u00a0\u202f]/g, ''));
return Number.isSafeInteger(number) && number >= 0 ? number : undefined;
}
function rowCount(row: EnvironmentObservation[], label: RegExp): number | undefined {
const values = new Set(row.filter(item => label.test(item.label.trim())).map(item => count(value(item))));
return values.size === 1 ? [...values][0] : undefined;
}
/** Presentation only. Linux/appliance roles are never guessed. */
export function operatingSystemGroup(name: string, roles: string[] = []): OperatingSystemGroup {
const explicit = new Set(roles.map(role => role.toLowerCase()).flatMap(role =>
['server', 'domain controller', 'member server'].includes(role) ? ['Servers' as const]
: ['client', 'workstation'].includes(role) ? ['Clients' as const] : []));
if (explicit.size > 1) return 'Other / unclassified';
if (explicit.size === 1) return [...explicit][0];
if (/\bwindows\s+server\b|^windows\s+(?:2003|2008|2012|2016|2019|2022|2025)\b|\bmac\s*os\s*x\s+server\b/i.test(name)) return 'Servers';
if (/^windows\s+(?:xp|vista|7|8(?:\.1)?|10|11)\b|^(?:macos|mac os x)\b/i.test(name)) return 'Clients';
return 'Other / unclassified';
}
/** Join only cells of the exact retained source row. Never add overlapping inventories. */
export function operatingSystemInventory(snapshot?: EnvironmentSnapshot): OperatingSystemInventoryGroup[] {
const rows = new Map<string, EnvironmentObservation[]>();
for (const observation of snapshot?.observations ?? []) {
if (observation.tool.id !== 'pingcastle' || observation.state !== 'observed') continue;
if (typeof observation.attributes.sourceTableIndex !== 'number' || observation.source.rowIndex === undefined) continue;
const key = JSON.stringify([observation.scanRunId, observation.importFileId, observation.sectionKey,
observation.attributes.sourceTableIndex, observation.source.rowIndex]);
const row = rows.get(key) ?? [];
row.push(observation);
rows.set(key, row);
}
const grouped = new Map<OperatingSystemGroup, OperatingSystemEntry[]>([
['Servers', []], ['Clients', []], ['Other / unclassified', []]
]);
const represented = new Set<string>();
for (const [id, row] of rows) {
const names = [...new Set(row.filter(item => item.property === 'operating_system.name').map(value).filter(Boolean))];
if (!names.length) continue;
const counts = new Set(row.filter(item => item.property === 'operating_system.count').map(item => count(value(item))));
const quantity = names.length === 1 && counts.size === 1 ? [...counts][0] : undefined;
const active = rowCount(row, /^nb active$/i);
const inactive = rowCount(row, /^nb inactive$/i);
const enabled = rowCount(row, /^nb enabled$/i);
const disabled = rowCount(row, /^nb disabled$/i);
const roles = row.filter(item => /^(?:type|role|computer type|os type)$/i.test(item.label.trim())).map(value);
const source = row[0];
const inventory = JSON.stringify([source.scanRunId, source.importFileId, source.sectionKey, source.attributes.sourceTableIndex]);
for (const name of names) {
represented.add(name);
grouped.get(operatingSystemGroup(name, roles))!.push({
id: `${id}:${name}`, name, count: quantity, active, inactive, enabled, disabled, inventory
});
}
}
// Older imports and other scanners can retain names without a row-bound count.
for (const name of new Set((snapshot?.observations ?? []).filter(item => item.state === 'observed'
&& item.property === 'operating_system.name').map(value).filter(Boolean))) {
if (!represented.has(name)) grouped.get(operatingSystemGroup(name))!.push({ id: `name:${name}`, name, inventory: 'unquantified' });
}
return [...grouped].map(([label, entries]) => {
const duplicates = new Map<string, number>();
for (const entry of entries) {
const key = JSON.stringify([entry.inventory, entry.name]);
duplicates.set(key, (duplicates.get(key) ?? 0) + 1);
}
const unique = entries.map(entry => duplicates.get(JSON.stringify([entry.inventory, entry.name]))! > 1
? { ...entry, count: undefined } : entry);
const deduplicated = [...new Map(unique.map(entry => [JSON.stringify([entry.inventory, entry.name]), entry])).values()];
const inventories = new Set(deduplicated.map(entry => entry.inventory));
const total = deduplicated.length > 0 && inventories.size === 1 && deduplicated.every(entry => entry.count !== undefined)
? deduplicated.reduce((sum, entry) => sum + entry.count!, 0) : undefined;
return { label, entries: deduplicated.sort((a, b) => (b.count ?? -1) - (a.count ?? -1) || a.name.localeCompare(b.name)),
total: total !== undefined && Number.isSafeInteger(total) ? total : undefined };
});
}

View File

@ -0,0 +1,54 @@
import type { FindingRecord } from '../../domain/finding';
export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain';
export const securityDomainCatalog = [
{ id: 'identity', label: 'Identity & Account Hygiene', glyph: 'user', pattern: /account|identity|user|password|stale|inactive|honey\s*pot/i },
{ id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i },
{ id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass/i },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema/i },
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|firewall|attack\s*surface|\basr\b|defender|operating\s*system/i },
{ id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i },
{ id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i },
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i }
] as const;
export const unclassifiedSecurityDomain = { id: 'unclassified', label: 'Unclassified' } as const;
const recordDomainEvidenceCache = new WeakMap<FindingRecord, { text: string; explicitGpo: boolean }>();
function recordDomainEvidence(record: FindingRecord): { text: string; explicitGpo: boolean } {
let cached = recordDomainEvidenceCache.get(record);
if (cached === undefined) {
const fields = record.fields;
cached = {
text: [record.title, ...fields
.filter(field => /^(?:title|name|finding|description|category|subcategory|sub-category|categories|control area|policy section|policy type)$/i.test(field.label.trim()))
.map(field => field.value)].join(' '),
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim()))
};
recordDomainEvidenceCache.set(record, cached);
}
return cached;
}
/** The one shared Client Info / Work classification for a retained record. */
export function securityDomainIdForRecord(record: FindingRecord): string {
const evidence = recordDomainEvidence(record);
if (evidence.explicitGpo) return 'gpo';
return securityDomainCatalog.find(domain => domain.pattern.test(evidence.text))?.id ?? unclassifiedSecurityDomain.id;
}
/** Assign every record once so Client Info distributions reconcile to the total. */
export function dashboardDomainAssignment(records: FindingRecord[]): Map<string, FindingRecord[]> {
const buckets = new Map<string, FindingRecord[]>([
...securityDomainCatalog.map(domain => [domain.id, [] as FindingRecord[]] as const),
[unclassifiedSecurityDomain.id, [] as FindingRecord[]] as const
]);
records.forEach(record => buckets.get(securityDomainIdForRecord(record))!.push(record));
return buckets;
}
export function dashboardDomainRecords(records: FindingRecord[], domainId: string): FindingRecord[] {
return dashboardDomainAssignment(records).get(domainId) ?? [];
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { previousPolicyReviews } from '../review-workspace/database-review';
import { useApp } from '../../app/AppProvider';
import { TriangleIcon } from '../../components/TriangleIcon';
import { DataTable } from '../../components/DataTable/DataTable';
@ -8,10 +9,10 @@ import { TileCard } from '../../components/TileCard/TileCard';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { linkResultSheets, resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { linkPingCastleSectionSheets, pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
import { resultMessageFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
import { resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { recordResultSheets } from '../linked-results/record-results';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
import { FindingReader } from '../finding-reader/FindingReader';
import { ClusterSlotIcon, FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
import {
@ -46,8 +47,12 @@ const TILE_GRID_INSET = 20;
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
// Enthielten Violet und Purple; beide ersetzt, weil violett als UI-Rolle
// vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
// "UI segments and tiles").
const resultColors = [
'#7c3aed', // Violet
'#0e7490', // Cyan
'#059669', // Emerald
'#dc2626', // Red
'#2563eb', // Blue
@ -60,7 +65,7 @@ const resultColors = [
'#4f46e5', // Indigo
'#ca8a04', // Gold
'#0369a1', // Sky Blue
'#9333ea', // Purple
'#78350f', // Brown
'#65a30d', // Lime
'#475569' // Slate
];
@ -227,11 +232,7 @@ export function DatasetViewer() {
(titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
const selected = records.find(record => record.id === selectedRecordId) ?? records[0];
const toolId = activeSheet.toolId ?? dataset.profileId;
const linksFor = (record: FindingRecord) => [
...linkResultSheets(record, dataset.sheets, activeSheet.id),
...linkPingCastleSectionSheets(record, dataset.sheets),
...(resultMessageFor(record, toolId) ? [] : inlineResultTableSheets(record))
];
const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId);
const messageFor = (record: FindingRecord) => resultMessageFor(record, activeSheet.toolId ?? dataset.profileId);
const sectionsFor = (record: FindingRecord) => resultSectionsFor(record, activeSheet.toolId ?? dataset.profileId);
const hasResults = (record: FindingRecord) => measureNavigationStep('result-links', () => linksFor(record).length > 0 || sectionsFor(record).length > 0);
@ -323,7 +324,19 @@ export function DatasetViewer() {
<>
<span ref={parkingAnchorRef} className="results-parking-anchor" aria-hidden="true" />
{!records.length ? (
<EmptyWorkspace showOpenData={false} />
<div className="work-filter-empty" role="status">
<h3>No results match the current filters</h3>
<p>The selected sheet contains {activeSheet.analysis.records.length.toLocaleString('en-US')} saved results. GPO comparisons awaiting review or re-evaluation are available under Result Status: All.</p>
<button className="btn" type="button" onClick={() => {
const source = app.facetFilters['provenance:source-scan'];
app.resetFilters();
app.setStatusFilter('all');
app.setActionOnly(false);
app.setSearch('');
app.setPathFindingIds([]);
if (source) app.setFacetFilter('provenance:source-scan', source);
}}>Show all results for this source</button>
</div>
) : view === 'columns' ? (
<div className="viewer-scroll tile-view-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
{tileWindow.paddingTop > 0 && <div aria-hidden="true" style={tileTopSpacerStyle} />}
@ -346,6 +359,7 @@ export function DatasetViewer() {
record={selected}
title={recordTitle(selected)}
review={reviews[selected.id]}
previousPolicyReviews={previousPolicyReviews(selected, reviews)}
linkedSheets={linksFor(selected)}
resultReferences={[...resultReferenceValues(selected), ...pingCastleSectionIds(selected)].filter(value => value.trim() !== (messageFor(selected) ?? '').trim())}
search={search}
@ -355,6 +369,7 @@ export function DatasetViewer() {
onPrevious={() => setSelectedRecord(records[Math.max(0, records.indexOf(selected) - 1)].id)}
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
onReview={patch => updateReview(selected.id, patch)}
onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined}
onResults={hasResults(selected) ? () => openResults(selected) : undefined}
resultsOpen={Boolean(resultColor(selected.id))}
resultColor={resultColor(selected.id)}
@ -409,6 +424,7 @@ export function DatasetViewer() {
// zwei Cluster-Fenstern; frei herausgezogene Fenster zaehlen nicht mit.
const isClusterTop = item.minimized && minimizedIndex === 0;
const isClusterBottom = item.minimized && minimizedIndex === minimizedWindows.length - 1;
const windowRecord = records.find(record => record.id === item.recordId);
return (
<FloatingResultsWindow
@ -417,6 +433,7 @@ export function DatasetViewer() {
title={item.title}
sheets={item.sheets}
toolId={item.toolId}
outcome={windowRecord ? resultOutcomeFor(windowRecord, toolId) : undefined}
sections={item.sections}
color={item.color}
zIndex={item.zIndex}

View File

@ -3,6 +3,7 @@ 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';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
const assessorApi = new AssessorApiClient();
@ -106,7 +107,10 @@ export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
if (backendStatus !== 'connected') {
return <div className="datahub-page">
<section className="datahub-offline" role="status"><div className="datahub-offline-icon" aria-hidden="true">!</div><div><h3>{backendStatus === 'checking' ? 'Checking server connection' : 'Backend is unreachable'}</h3><p>The database structure will be available again once the local API is connected.</p></div></section>
<BackendConnectionDialog
backendStatus={backendStatus}
message="The database structure will be available again once the local API is connected."
/>
</div>;
}

View File

@ -1,8 +1,11 @@
import { useState, type ChangeEvent, type CSSProperties } from 'react';
import { Fragment, useState, type ChangeEvent, type CSSProperties } from 'react';
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord, RecordField } from '../../domain/finding';
import type { Review } from '../../domain/review';
import { defaultReview } from '../../domain/review';
import { riskAcceptanceState, type RiskAcceptanceCommand } from '../../domain/risk-acceptance';
import { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
import { PolicySourceEvidence } from './PolicySourceEvidence';
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText';
@ -15,8 +18,8 @@ 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 { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
import { policyDiagnosticBadges, 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';
@ -24,6 +27,7 @@ interface Props {
record: FindingRecord;
title: string;
review?: Review;
previousPolicyReviews?: Array<{ id: string; baseline: string; review: Review }>;
linkedSheets: DatasetSheet[];
resultReferences: string[];
search: string;
@ -34,6 +38,7 @@ interface Props {
onPrevious(): void;
onNext(): void;
onReview(patch: Partial<Review>): void;
onRiskAcceptance?(command: RiskAcceptanceCommand): Promise<void>;
onResults?(): void;
/** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */
resultsOpen?: boolean;
@ -198,12 +203,18 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
}
const statusField = resultStatusFieldFor(record, toolId);
const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0;
const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length);
if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
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 policyComparisonFields = toolId === 'group-policy' && (!hasPolicyEvidenceResults || hasPolicySourceEvidence) ? record.fields.filter(field => {
const label = normalizeLabel(field.label);
return field.value && (label === 'configured value' || label === 'baseline value');
return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || label === 'baseline value');
}) : [];
const policyReadiness = toolId === 'group-policy'
? record.fields.find(field => normalizeLabel(field.label) === 'evaluation readiness' && field.value) : undefined;
if (policyReadiness) hiddenFieldIndexes.add(policyReadiness.index);
policyComparisonFields.forEach(field => hiddenFieldIndexes.add(field.index));
const badges = record.fields.filter(field => {
if (!field.value) return false;
@ -260,12 +271,53 @@ 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, policyComparisonFields };
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness };
}
export function FindingReader({ record, title, review: storedReview, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onResults, resultsOpen, resultColor }: Props) {
/**
* Short technical token that can be pasted into Policy Analyzer. This is a
* presentation aid only: evaluation and finding identity continue to use the
* complete technical policy identity.
*/
export function policyAnalyzerSearchKeys(record: FindingRecord, title: string, toolId?: string): string[] {
if (toolId !== 'group-policy') return [];
const field = (label: string) => record.fields.find(item => normalizeLabel(item.label) === label)?.value.trim() ?? '';
const preference = field('policy type') === 'group-policy-preference';
const technicalTitle = title.split(/\s*\u00b7\s*/).at(-1)?.trim() ?? title.trim();
const titleParts = technicalTitle.split(/[\\/]/).map(part => part.trim()).filter(Boolean);
const keys: string[] = [];
const add = (value?: string) => {
const trimmed = value?.trim();
if (trimmed && !keys.some(item => item.toLocaleLowerCase('en-US') === trimmed.toLocaleLowerCase('en-US'))) keys.push(trimmed);
};
for (const item of record.fields) {
if (!/^(?:configured value|baseline value)$/.test(normalizeLabel(item.label))) continue;
if (preference) {
try {
const values = JSON.parse(item.value) as unknown;
const visit = (value: unknown): void => {
if (Array.isArray(value)) return value.forEach(visit);
if (!value || typeof value !== 'object') return;
const entry = value as Record<string, unknown>;
if (entry.kind === 'registry-preference' && typeof entry.key === 'string') add(entry.key.split(/[\\/]/).at(-1));
Object.values(entry).forEach(visit);
};
visit(values);
} catch { /* The unchanged technical title remains the safe fallback. */ }
}
add(policyValueName(item.value));
}
if (!keys.length && preference && titleParts.length > 1) add(titleParts.at(-2));
if (preference || !keys.length) add(titleParts.at(-1));
return keys;
}
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, onResults, resultsOpen, resultColor }: Props) {
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
const linkedResultsOpen = openLinkedResultsFor === record.id;
const review = { ...defaultReview(), ...storedReview };
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
@ -273,14 +325,16 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
const hasStructuredResults = linkedSheets.length > 0;
const resolvedToolId = toolIdForRecord(record, toolId);
const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
const policySearchKeys = policyAnalyzerSearchKeys(record, title, resolvedToolId);
// 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.
const fileReferences = [...new Set(resultReferences.filter(isFileReference))];
const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
return (
<article className="finding-reader">
@ -294,7 +348,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
<div className="reader-heading">
<span className={`reader-title-hex severity-${severity}`} aria-hidden="true" />
<div>
<h2>{highlightText(title, search)}</h2>
<h2><PathText value={title} search={search}/></h2>
<div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
@ -308,13 +362,20 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
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>)}
{policyDiagnosticBadges(record, resolvedToolId).map(diagnostic => <span className="status-badge policy-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={`policy-diagnostic-${diagnostic}`}>{diagnostic}</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>
<button className={`btn accepted-risk-trigger${riskState === 'Draft' ? ' is-draft' : ''}`} data-risk-acceptance-state={riskState?.toLowerCase() ?? 'not-recorded'} type="button" aria-haspopup="dialog" onClick={() => setRiskAcceptanceFor(record.id)}>
Accepted Risk{riskState
? <small>{riskState}</small>
: ' ?'}
</button>
<div className="reader-heading-actions">
{policySearchKeys.length > 0 && <div className="policy-search-key"><span>Policy Analyzer search {policySearchKeys.length === 1 ? 'key' : 'terms'}</span><div>{policySearchKeys.map(key => <code key={key}>{highlightText(key, search)}</code>)}</div></div>}
{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>
@ -323,6 +384,7 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
<button className={`btn results-detail-btn results-btn${resultsOpen ? ' is-open' : ''}`} style={resultButtonStyle} type="button" disabled={!onResults} onClick={onResults}>Results</button>
</div>
</div>
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={record.id} title={title} occurrenceId={record.findingOccurrenceId} value={review.riskAcceptance} onSave={onRiskAcceptance} onClose={() => setRiskAcceptanceFor('')} />}
<section className="dashboard-grid">
<label>Status<select value={review.status} onChange={updateText('status')}>{statuses.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Priority<select value={review.priority} onChange={updateText('priority')}>{priorities.map(value => <option key={value}>{value}</option>)}</select></label>
@ -330,13 +392,29 @@ 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(policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding'), search)}</h3>
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={comparisonOutcome}>
{Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews">
<summary>Previous baseline-specific reviews ({previousPolicyReviews!.length})</summary>
<p>These earlier notes remain attached to their original comparisons. Different decisions and risk acceptances are not automatically applied to this combined setting.</p>
{previousPolicyReviews!.map(item => <section key={item.id}>
<h4>{item.baseline}</h4>
<dl>{Object.entries(item.review).filter(([key, value]) => value !== undefined
&& JSON.stringify(value) !== JSON.stringify(defaultReview()[key as keyof Review])).map(([key, value]) =>
<Fragment key={key}><dt>{key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, character => character.toUpperCase())}</dt>
<dd>{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}</dd></Fragment>)}</dl>
</section>)}
</details>}
<h3><PathText value={policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding')} search={search}/></h3>
{policyReadiness && <div className="policy-evaluation-warning" role="status"><h4>Historical comparison</h4><p>{policyReadiness.value}</p></div>}
{resolvedToolId === 'group-policy' && Boolean(record.policySourceEvidence?.length) && <PolicySourceEvidence sources={record.policySourceEvidence!} search={search}/>}
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={policyReadiness ? 'historical' : 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>
<h4>{policyReadiness ? (expected ? 'Saved baseline value' : 'Saved customer value') : expected ? 'Expected baseline value' : 'Configured customer value'}</h4>
<p className="policy-guidance-text">{policyComparisonDisplayLines(field.value).map((line, index) =>
<Fragment key={index}>{index > 0 && '\n'}{line.emphasized
? <strong className="policy-value-emphasis"><PathText value={line.text} search={search}/></strong>
: <PathText value={line.text} search={search}/>}</Fragment>)}</p>
</section>;
})}
</div>}
@ -382,4 +460,3 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
</article>
);
}

View File

@ -0,0 +1,42 @@
import { Fragment } from 'react';
import type { PolicySourceEvidence as SourceEvidence } from '../../domain/finding';
import { PathText } from '../../components/PathText/PathText';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { policySourceGroups, policySourceValueLines } from '../../profiles/group-policy/source-evidence';
export function PolicySourceEvidence({ sources, search }: { sources: SourceEvidence[]; search: string }) {
const groups = policySourceGroups(sources);
const identified = groups.filter(group => group.identified).length;
const unassigned = groups.length - identified;
return <section className="policy-source-evidence" aria-label="GPO source contributions">
<h4>{identified} GPO backup {identified === 1 ? 'source' : 'sources'}{unassigned > 0 ? ` · ${unassigned} unassigned` : ''}</h4>
<p className="policy-source-notice">Original instructions by GPO. This list is not an execution order between GPOs and does not establish the effective endpoint value.</p>
<div className="policy-source-groups">
{groups.map(group => {
const relation = group.observations.length === 1 ? group.observations[0].baselineRelation : undefined;
return <article className={`policy-source-group${relation ? ` is-${relation}` : ''}`}
data-source-label={group.label} data-source-relation={relation ?? 'unassessed'} key={group.id}>
<header className="policy-source-group__header">
<h5>{highlightText(group.label, search)}</h5>
{relation === 'review' && <span className="policy-source-group__badge is-review">Review required</span>}
</header>
{group.backupLabel && <p className="policy-source-group__backup"><strong>{highlightText(group.backupLabel, search)}</strong></p>}
{group.observations.map((observation, observationIndex) => <div className="policy-source-observation" key={observation.id}>
<p className="policy-source-reference"><PathText value={observation.sourceReference} search={search}/></p>
{observation.sequence !== undefined && <p className="policy-source-sequence">Source sequence: {observation.sequence}</p>}
{observation.values.map((value, valueIndex) => <p className="policy-guidance-text policy-source-value" key={valueIndex}>
{policySourceValueLines(value, observation.deviationFields?.[valueIndex], observation.deviationItems?.[valueIndex]).map((line, lineIndex) => {
const content = line.emphasized
? <strong className="policy-value-emphasis"><PathText value={line.text} search={search}/></strong>
: <PathText value={line.text} search={search}/>;
return <Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.deviating
? <span className="policy-source-deviation-value">{content}</span> : content}</Fragment>;
})}
</p>)}
{observationIndex < group.observations.length - 1 && <hr/>}
</div>)}
</article>;
})}
</div>
</section>;
}

View File

@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from 'react';
import { Dialog } from '../../components/Dialog/Dialog';
import { emptyRiskAcceptanceDetails, riskAcceptanceIssues, riskAcceptanceState, type RiskAcceptance,
type RiskAcceptanceAction, type RiskAcceptanceCommand, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
type Field = { key: keyof RiskAcceptanceDetails; label: string; kind?: 'date' | 'risk' | 'short'; help?: string };
const groups: Array<{ title: string; fields: Field[] }> = [
{ title: 'Scope and business decision', fields: [
{ key: 'scope', label: 'Affected assets, systems and exclusions', help: 'Define exactly where this exception applies. This record is linked to the current finding occurrence.' },
{ key: 'scenario', label: 'Risk scenario', help: 'Threat, weakness, possible event and business consequence.' },
{ key: 'businessReason', label: 'Reason for accepting the risk' },
{ key: 'alternatives', label: 'Alternatives considered', help: 'Document mitigation, avoidance and transfer options, their costs and why they were not chosen.' },
{ key: 'trackingReference', label: 'Risk register / ticket reference', kind: 'short' },
{ key: 'obligationsReview', label: 'Policy, contractual and regulatory review', help: 'Record applicable obligations, consulted specialists and restrictions. Acceptance does not waive mandatory requirements.' }
] },
{ title: 'Risk assessment and controls', fields: [
{ key: 'likelihood', label: 'Likelihood and supporting assumptions' },
{ key: 'impact', label: 'Business impact', help: 'Consider confidentiality, integrity, availability, financial and operational impact.' },
{ key: 'initialRisk', label: 'Initial risk level', kind: 'risk' },
{ key: 'residualRisk', label: 'Residual risk level', kind: 'risk' },
{ key: 'residualAssessment', label: 'Residual risk assessment', help: 'Explain the remaining exposure and why it is within the approving authority’s tolerance.' },
{ key: 'compensatingControls', label: 'Compensating controls', help: 'If none, state this explicitly and explain why.' },
{ key: 'controlOwner', label: 'Control owner', kind: 'short' },
{ key: 'controlEvidence', label: 'Control effectiveness and verification evidence' }
] },
{ title: 'Responsibility and documented approval', fields: [
{ key: 'riskOwner', label: 'Risk owner', kind: 'short' },
{ key: 'riskOwnerRole', label: 'Risk owner role / team', kind: 'short' },
{ key: 'riskOwnerContact', label: 'Risk owner contact', kind: 'short' },
{ key: 'approver', label: 'Independent approving authority', kind: 'short' },
{ key: 'approverRole', label: 'Approver role and decision authority', kind: 'short' },
{ key: 'approverContact', label: 'Approver contact', kind: 'short' },
{ key: 'approvalReference', label: 'Approval evidence reference', help: 'Reference an explicit, signed or otherwise attributable approval covering this scope and validity period.' },
{ key: 'approvedOn', label: 'External approval date', kind: 'date' }
] },
{ title: 'Validity, monitoring and exit', fields: [
{ key: 'validFrom', label: 'Valid from', kind: 'date' },
{ key: 'expiresOn', label: 'Expires on', kind: 'date' },
{ key: 'reviewOn', label: 'Next review', kind: 'date' },
{ key: 'monitoring', label: 'Monitoring plan and review owner', help: 'Specify what is monitored, by whom, how often and when to escalate.' },
{ key: 'reassessmentTriggers', label: 'Early reassessment / revocation triggers', help: 'For example: incidents, changed exposure, failed controls or changed business requirements.' },
{ key: 'exitPlan', label: 'Exit / remediation plan and milestones' },
{ key: 'evidenceReferences', label: 'Additional evidence references (optional)', help: 'Use reference IDs or locations. This form does not upload or verify evidence files.' }
] }
];
interface Props {
title: string;
occurrenceId?: string;
value?: RiskAcceptance;
onSave?: (command: RiskAcceptanceCommand) => Promise<void>;
onClose(): void;
}
const actionLabels: Record<RiskAcceptanceAction, string> = {
draft: 'Save draft',
accept: 'Record acceptance',
revoke: 'Revoke acceptance'
};
export function RiskAcceptanceDialog({ title, occurrenceId, value, onSave, onClose }: Props) {
const latest = value?.revisions.at(-1);
const [details, setDetails] = useState<RiskAcceptanceDetails>(() => ({ ...emptyRiskAcceptanceDetails(), ...latest?.details }));
const [recordedBy, setRecordedBy] = useState('');
const [changeReason, setChangeReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const [saved, setSaved] = useState('');
const [pendingCommand, setPendingCommand] = useState<RiskAcceptanceCommand>();
const form = useRef<HTMLFormElement>(null);
const feedback = useRef<HTMLDivElement>(null);
const dirty = useRef(false);
useEffect(() => { if (errors.length || saved) feedback.current?.focus(); }, [errors, saved]);
const closeRef = useRef(() => undefined as void);
closeRef.current = () => {
if (!busy && (!dirty.current || window.confirm('Discard unsaved risk acceptance entries?'))) onClose();
};
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const dialog = form.current?.closest<HTMLElement>('[role="dialog"]');
const focusable = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]') ?? [])];
form.current?.querySelector<HTMLElement>('input, textarea')?.focus();
const keyboard = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); closeRef.current(); }
if (event.key !== 'Tab') return;
const elements = focusable();
if (event.shiftKey && document.activeElement === elements[0]) { event.preventDefault(); elements.at(-1)?.focus(); }
else if (!event.shiftKey && document.activeElement === elements.at(-1)) { event.preventDefault(); elements[0]?.focus(); }
};
document.addEventListener('keydown', keyboard);
return () => { document.removeEventListener('keydown', keyboard); previous?.focus(); };
}, []);
const edit = (field: keyof RiskAcceptanceDetails, text: string) => {
dirty.current = true; setSaved(''); setConfirmed(false); setDetails(current => ({ ...current, [field]: text }));
};
const review = (action: RiskAcceptanceAction) => {
if (!onSave || busy) return;
const command = { action, details, recordedBy, changeReason };
const issues = riskAcceptanceIssues(command, new Date().toISOString().slice(0, 10));
if (action === 'accept' && !confirmed) issues.push('Confirm that the external approval covers the documented risk and scope.');
setErrors(issues);
if (issues.length) return;
setSaved('');
setPendingCommand(command);
};
const save = async () => {
if (!onSave || busy || !pendingCommand) return;
setBusy(true); setSaved('');
try {
await onSave(pendingCommand);
dirty.current = false;
setSaved(pendingCommand.action === 'draft' ? 'Draft saved successfully.' : pendingCommand.action === 'revoke' ? 'Revocation saved successfully.' : 'Acceptance saved successfully.');
setChangeReason(''); setConfirmed(false);
} catch (error) { setErrors([error instanceof Error ? error.message : 'Could not save. Your entries are still available.']); }
finally { setBusy(false); setPendingCommand(undefined); }
};
const completedFields = pendingCommand ? groups.map(group => ({
...group,
fields: group.fields.filter(field => pendingCommand.details[field.key].trim())
})).filter(group => group.fields.length) : [];
return <Dialog title={pendingCommand ? 'Review entry' : 'Accepted Risk'} className={pendingCommand ? 'risk-acceptance-review-dialog' : 'risk-acceptance-dialog'} onClose={() => pendingCommand ? setPendingCommand(undefined) : closeRef.current()} dismissible={!busy}>
{pendingCommand ? <section className="risk-acceptance-review" aria-label="Accepted Risk entry review">
<div className="risk-acceptance-review-intro">
<strong>{actionLabels[pendingCommand.action]}</strong>
<p>Review your information before it is saved.</p>
</div>
<dl className="risk-acceptance-review-meta">
<div><dt>Finding</dt><dd>{title}</dd></div>
<div><dt>Recorded by</dt><dd>{pendingCommand.recordedBy}</dd></div>
<div><dt>Change / decision reason</dt><dd>{pendingCommand.changeReason}</dd></div>
</dl>
{completedFields.map(group => <section className="risk-acceptance-review-group" key={group.title}>
<h3>{group.title}</h3>
<dl>{group.fields.map(field => <div key={field.key}><dt>{field.label}</dt><dd>{pendingCommand.details[field.key]}</dd></div>)}</dl>
</section>)}
{pendingCommand.action === 'accept' && <p className="risk-acceptance-review-confirmation">External approval coverage was confirmed for this risk, scope, residual exposure and validity period.</p>}
<div className="risk-acceptance-review-actions">
<button className="btn primary" type="button" disabled={busy} onClick={() => void save()}>OK</button>
<button className="btn" type="button" disabled={busy} onClick={() => setPendingCommand(undefined)}>Change</button>
</div>
</section> : <form ref={form} className="risk-acceptance-form" onSubmit={event => { event.preventDefault(); review('draft'); }}>
<p className="risk-acceptance-context">{title}</p>
<p>Decision state: <strong>{riskAcceptanceState(value, occurrenceId)}</strong>. Technical findings remain unchanged.</p>
<p className="risk-acceptance-note">Record an external approval here. Names and references are documented, not authenticated signatures. Complete all fields except optional evidence before recording acceptance.</p>
{latest?.action === 'accept' && <p>A new draft replaces the active decision state until acceptance is recorded again. Earlier approvals remain in the history.</p>}
{!onSave && <p role="status">Open a saved finding with a connected backend to save this record.</p>}
{(errors.length > 0 || saved) && <div ref={feedback} tabIndex={-1}>
{errors.length > 0 && <div role="alert"><strong>Review the following entries:</strong><ul>{errors.map((error, index) => <li key={index}>{error.replace(/^(\w+):/, (_, key: string) => `${groups.flatMap(group => group.fields).find(field => field.key === key)?.label ?? key}:`)}</li>)}</ul></div>}
{saved && <p role="status">{saved}</p>}
</div>}
<fieldset disabled={busy} className="risk-acceptance-fields">
{groups.map(group => <section key={group.title}>
<h3>{group.title}</h3>
<div className="risk-acceptance-grid">{group.fields.map(field => <label key={field.key}>
<span>{field.label}</span>
{field.kind === 'risk' ? <select value={details[field.key]} onChange={event => edit(field.key, event.target.value)}><option value="">Select level</option>{['Low', 'Medium', 'High', 'Critical'].map(level => <option key={level}>{level}</option>)}</select>
: field.kind ? <input type={field.kind === 'date' ? 'date' : 'text'} maxLength={12000} value={details[field.key]} onChange={event => edit(field.key, event.target.value)} />
: <textarea maxLength={12000} rows={3} value={details[field.key]} onChange={event => edit(field.key, event.target.value)} />}
{field.help && <small>{field.help}</small>}
</label>)}</div>
</section>)}
<section><h3>Record this revision</h3><div className="risk-acceptance-grid">
<label><span>Recorded by</span><input maxLength={500} value={recordedBy} onChange={event => { dirty.current = true; setRecordedBy(event.target.value); }} /></label>
<label><span>Change / decision reason</span><textarea maxLength={4000} value={changeReason} onChange={event => { dirty.current = true; setChangeReason(event.target.value); }} /></label>
</div></section>
<label className="risk-acceptance-confirm"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} /><span>I have verified that the referenced approval explicitly covers this risk, scope, residual exposure and validity period.</span></label>
</fieldset>
<div className="risk-acceptance-actions">
<button className="btn" type="submit" disabled={busy || !onSave}>Save draft</button>
<button className="btn" type="button" disabled={busy || !onSave} onClick={() => review('accept')}>Record acceptance</button>
{value?.revisions.some(revision => revision.action === 'accept') && <button className="btn" type="button" disabled={busy || !onSave || latest?.action === 'revoke'} onClick={() => review('revoke')}>Revoke acceptance</button>}
<button className="btn" type="button" disabled={busy} onClick={() => closeRef.current()}>Close</button>
</div>
{value?.revisions.length ? <section className="risk-acceptance-history"><h3>Decision history</h3>
{[...value.revisions].reverse().map(revision => <details key={revision.revision}><summary>Revision {revision.revision} · {revision.action} · {revision.recordedAt}</summary>
<p>Recorded by: {revision.recordedBy}</p><p>{revision.changeReason}</p>
<dl>{groups.flatMap(group => group.fields).map(field => <div key={field.key}><dt>{field.label}</dt><dd>{revision.details[field.key] || '—'}</dd></div>)}</dl>
</details>)}
</section> : null}
</form>}
</Dialog>;
}

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