Business Impact + Management Report

This commit is contained in:
Michael 2026-10-04 19:58:04 +02:00
parent d234f94315
commit 250c213fc1
167 changed files with 7279 additions and 504 deletions

View File

@ -132,8 +132,9 @@
- Explicit user correction 2026-09-25: changing `Source: Scan` inside Work, including back to `All`, starts the newly selected source with the clean Work default. Clear the previous search, status override, other facets, record-ID/path restriction and `My Workspace` restriction, restore the standard `Deviation` status and normal sort, then apply the selected source. Keep the current Work view mode and current client/timeslot. This applies to an in-Work source change, not to an incoming Client Info or Pathfinder drilldown before the user changes Source.
- 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.
- [The `GPO & Baseline` mapping is superseded by the 2026-10-04 Security Domain decision below.] 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.
- [Eight entries and `Unclassified` superseded by the 2026-10-04 decision below: six entries, no `Unclassified`; the remaining rules stay valid.] 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-10-04 supersedes the eight-domain catalog, the text-pattern assignment, `GPO & Baseline` and `Unclassified`: there are exactly six Security Domains in this order: `Identity & Authentication`, `Privileged Access & Delegation`, `AD Infrastructure & PKI`, `System Hardening`, `Cloud & Collaboration`, `Monitoring & Recovery`. GPO is a delivery path, not a domain; the GPO view stays available through `Source: Scan = GPOs` and the GPO facets. One classifier in Evaluation (`src/evaluation/security-domains.ts`) serves Work, the Work facet, Client Info and the report projection. It reads only the fields Raw Data shows for a record and never the title, description, remediation prose or the outcome, so pass and fail follow the same rules. Each check gets exactly one domain from the first tier with a hit: (1) native category, exact rule ID or native tag, (2) CIS-CAT `Benchmark section` title path from the same scan report (never the number alone), (3) suite or control ID, (4) GPO technical identity (`Policy type`, `Policy section`, `Registry path` by longest key), (5) the source's own fallback area, else a visible global fallback. Mapping tables are versioned per scanner profile (`src/profiles/<tool>/<tool>-security-domains.ts`); a new scanner brings its table or lands deliberately in tier 5. Every assignment carries tier, method and deciding field; per source, the tier-5 share shows where a table needs entries. Entra checks (EIDSCA, CISA MS.AAD, Maester/Entra, Conditional Access, MFA, Identity Protection) are Identity & Authentication; Exchange, Teams, SharePoint, ORCA, Intune and other tenant configuration are Cloud & Collaboration; Purple Knight `Group Policy Security` is System Hardening. The derived Raw Data fields `Benchmark section` (CIS-CAT) and, for security-template settings without a comparison, `Policy section` and `Registry path` are display projections of retained facts; they need no reimport. The assignment never depends on the Context Engine.
- 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.
@ -226,7 +227,7 @@
- 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. The unassessed count remains visible as `Gaps` in Data; 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-25: Security Domain rows are compact enough for all eight domains to fit beside Data without clipping. Domain names use the shared H3 role; the score node and right-hand deviation or review count remain, without the former metadata line. 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.
- [Eight domains superseded by the six-domain decision of 2026-10-04 in `UI Work filter context`; the compact-row rule applies to all six.] Explicit user correction 2026-09-25: Security Domain rows are compact enough for all eight domains to fit beside Data without clipping. Domain names use the shared H3 role; the score node and right-hand deviation or review count remain, without the former metadata line. 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-25: Within each Client Info Security Domain row, center the domain name, amount bar and amount number vertically on the center of that row's score circle. They belong to one grid row; do not let the circle span an unused second row that shifts the other elements upward.
- 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.

105
docs/CLIENT_INFORMATION.md Normal file
View File

@ -0,0 +1,105 @@
# Client Information
Stand: 2026-10-04. Core capability seit ASSESSOR 2.14.0; Cloud-Uebersicht 2.14.2, read contract 1.1.0.
Client Information bezeichnet die bisher als scannerbezogene Zusatzinformationen behandelten Beobachtungen und Original-Berichtsabschnitte. Der vorhandene Bestand bleibt in `EnvironmentObservation` und `ScannerReportSection` gespeichert. Es gibt keine neue Datenbank, Tabelle, Migration oder zweite Kopie des Bestands. Bestehende Namen auf Persistenzebene bleiben kompatibel. Die technischen Einzelbefundfelder im Finding Reader unter Additional Information und die Stammdaten Name/Consultant sind andere bestehende Funktionen.
## Gezielter Abruf
Aktuelle Korrektur 2.14.2: Die unten historisch dokumentierten Verfuegbarkeitshinweise der Cloud-Kachel sind durch konkrete berichtete Werte bzw. `Not reported` ersetzt. Details und Grenzen stehen unter **Konkrete Cloud-Uebersicht**; die permanente Sichtbarkeit und der Results-Scope bleiben verbindlich.
Client Info besitzt den Button **Client Information**. Er oeffnet das gemeinsame Datenfenster fuer den ausgewaehlten Mandanten und die ausgewaehlten Scanlaeufe. Erst beim Oeffnen beginnt die Abfrage. Bereich, Freitext, exakter Eigenschaftsschluessel und exakte Objektidentitaet schraenken die Ergebnisse ein. Details zeigen den kompletten Originalwert, Erfassungszustand, Erfassungszeit (falls von der Quelle angegeben), Datei, Scanlauf, Quellposition und Mapping-Metadaten. Vorhandene Quellabschnitte behalten ihre Tabellen und Verweise. Apply und Reset wirken nur auf diese Abfrage, nicht auf Work.
Nutzerentscheidung 2026-10-04, umgesetzt ab 2.14.1: Die zusaetzliche **Cloud Information**-Kachel in der bestehenden Umgebungskachelreihe ist immer sichtbar, auch ohne Cloud-Nachweise. Ihre vier Felder zeigen die Verfuegbarkeit von Quellnachweisen fuer Sign-in/Conditional Access, Rollen/Berechtigungen, Application consent und externen Zugriff/Sharing. `No retained information` kennzeichnet fehlende Nachweise; `Source evidence not assessed` kennzeichnet vorhandene, nicht als beobachtet erfasste Nachweise. Keiner dieser Texte leitet aus einem Teststatus eine konkrete Konfiguration ab. Results oeffnet denselben Client-Information-Browser, eingeschraenkt auf die vier Cloud-Bereiche; Bereichsauswahl und Reset bleiben innerhalb dieses Cloud-Scope. Ohne gespeicherten Mandanten zeigt Results den bestehenden leeren Quellenhinweis, ohne eine API-Abfrage zu erfinden. Die immer sichtbare Kachel ersetzt die zuvor vorgeschlagene Bedingung, sie nur bei vorhandenen Cloud-Daten einzublenden.
Der Jetzt-Zustand ist der aktuelle **importierte** Bestand im gewaehlten Scope. Er ist kein Liveinventar. Ohne explizite Zeitraumauswahl bedeutet die API alle nicht ersetzten Importlaeufe des Mandanten, nicht automatisch den zeitlich letzten Timeslot. Unterschiedliche Quellen, widerspruechliche Werte und fehlende Zeitangaben werden nicht zu einem vermeintlich eindeutigen aktuellen Wert verschmolzen.
HTTP: `GET /api/v1/projects/{projectId}/client-information`
| Parameter | Bedeutung |
| --- | --- |
| `assessmentId` | Optionaler Timeslot, muss zum Mandanten gehoeren |
| `scanRunIds` | Exakte Scanlaeufe; wiederholter Parameter |
| `includeSuperseded` | Standard `false`; `true` erlaubt auch ersetzte historische Importlaeufe |
| `areas` | Fachliche Bereiche, wiederholbar |
| `properties` | Exakte Eigenschaftsschluessel, wiederholbar |
| `subjectTypes`, `subjectIds` | Exakter Objekttyp / Quellidentitaet, wiederholbar |
| `toolIds`, `states` | Exakte Scanner-ID / Erfassungszustaende, wiederholbar |
| `search` | Teiltext in Quellwert, Label, Eigenschaft, Objekt; bei Abschnitten auch Text/Tabellen |
| `offset`, `limit` | Beobachtungsseite; Standard 0/50, maximal 200 pro Seite |
| `sectionOffset`, `sectionLimit` | Seite primaerer Quellabschnitte; Standard 0/10, maximal 50 |
Selektoren einer Liste werden mit ODER, verschiedene Listen mit UND verknuepft. Fehlende oder leere Listen beschraenken nicht. Erlaubt sind maximal 100 Eintraege pro Liste und 500 Zeichen pro Eintrag oder Suchtext. Unbekannte Parameter/Bereiche/Zustaende, ungueltige Seitengroessen, doppelte skalare HTTP-Parameter und fremde Mandanten-/Timeslot-/Scan-Zuordnungen werden abgewiesen.
Bereichsschluessel: `directory`, `inventory`, `resilience`, `password-policy`, `certificates`, `cloud-access`, `cloud-roles`, `cloud-apps`, `cloud-sharing`, `source-metadata`, `other`. Die Zuordnung nutzt deklarierte Importmetadaten oder stabile Eigenschafts-/Objekt-/Abschnittsschluessel; keine Bewertung aus Ueberschriften.
Beispiel: `/api/v1/projects/synthetic-client/client-information?assessmentId=synthetic-slot&properties=domain.fqdn&limit=50`.
Die Antwort erhaelt den Snapshot mit `contractVersion`, `purpose: internal-analysis`, `repositoryRevision`, `scopeMode`, getrennten Gesamtsummen und Seitengrenzen. `coverage` beschreibt den vollstaendigen gefilterten Bestand, nicht nur die Seite. Beobachtungen sind nach property/id, Abschnitte nach ordinal/id sortiert. Passende Originalabschnitte bleiben vollstaendig; sie koennen weitere Werte enthalten als die ausgewaehlten Beobachtungen. Exakte, eindeutige Linkziele derselben Datei und desselben Laufs werden auch ueber Seitengrenzen mitgeliefert und in `linkedSectionIds` benannt. Sie zaehlen nicht zur primaeren Seitengroesse. Mehrdeutige Ziele werden nicht geraten.
Bei mehreren Seiten `repositoryRevision` vergleichen. Aendert sie sich, die Seitenauswahl neu beginnen; die API stellt keinen dauerhaft reservierten Snapshot-Token bereit. Jede einzelne Abfrage prueft die committed Revision vor und nach dem Lesen und wiederholt bei Aenderung hoechstens dreimal, danach folgt ein Konfliktfehler. PostgreSQL liest dafuer die Datenbankrevision, nicht nur den lokalen Prozesscache. Historischer Abruf liefert erhaltene Importbeobachtungen, keine zeitpunktbezogene Rekonstruktion geloeschter Daten oder alter Repository-Revisionen.
## Read-only-Port fuer Engines
Der veroeffentlichte Vertrag ist `src/persistence/client-information-read-port.ts` (`ClientInformationReadPort`). UI/API und spaetere Engines nutzen `queryClientInformation` mit demselben `ClientInformationQuery` aus `src/domain/client-information.ts`.
Die Backend-Komposition bindet den Port an eine **read-only** Repository-/Datenbanksitzung und reicht nur den eingefrorenen Port an die Engine weiter:
```ts
const port = clientInformationReadPortFrom(readOnlyRepository,
() => readOnlyRepository.getCommittedRevision());
const information = await port.queryClientInformation({
projectId, assessmentId, scanRunIds,
areas: ['cloud-access'], limit: 100
});
```
Das ist Kompositionscode, kein direkter Repository-/Tabellenzugriff aus einer Engine. Engines bekommen keine Mutationsmethoden. Die Rueckgabe ist eine unabhaengige Kopie; Veraenderungen durch einen Leser veraendern den Core nicht. Optionalen Engines ausgeschaltet zu lassen hat keinen Einfluss auf diese Core-Funktion. Bestehende Context-/Report-Ports bleiben kompatibel und werden nicht still auf neue Inputs umgestellt.
Diese Daten dienen interner Analyse. Der Port ist keine Result-DB- oder Exportfreigabe. Was eine Engine anzeigt oder exportiert, muss weiter die bestehenden Disclosure- und Datenschutzregeln durchlaufen. Cloud-Testnachweise werden nicht als neue Result-DB-Zeilen oder Findings veroeffentlicht. Der neue Port fuegt keine Bewertung, Prioritaet oder Scoreberechnung hinzu.
## Neuer fachlicher Mehrwert und Grenzen
| Quelle | Bereits vorhandener Bestand | Neu in dieser Umsetzung |
| --- | --- | --- |
| PingCastle | Directory, Inventar, Resilienz, Kennwortrichtlinien, PKI und erhaltene Originaltabellen | Gezielter einheitlicher Zugriff; keine doppelte AD-Extraktion |
| Purple Knight | Bereits erhaltene Assessment-/Reportinformationen | Derselbe Zugriff auf den vorhandenen Bestand |
| CIS-CAT | Bereits erhaltene Host-/Benchmark-/Berichtsinformationen | Derselbe Zugriff auf den vorhandenen Bestand |
| Maester | Tenant, Reportmetadaten und Testzaehler | Erhaltene Detailnachweise zu Anmeldung/Conditional Access, Rollen, App-Zustimmung und externem Zugriff/Sharing separat auffindbar |
| Andere Profile | Nur tatsaechlich bereits erzeugte Beobachtungen/Abschnitte | Gemeinsamer Zugriff; keine behauptete neue Quellabdeckung |
Maester HTML (`ResultDetail.TestResult`) sowie Excel/CSV mit exakter bekannter Regel-ID und getrennten Status-/Detailspalten werden erweitert. CSV `Result` plus `ResultDetail` und Excel/CSV `Status` plus `Result` werden unterstuetzt. Die generische Tabellenpipeline verarbeitet weiterhin saemtliche Zeilen. JSON/XML und beliebige unbekannte Exportspalten erhalten durch diesen Schritt keine neue Cloud-Semantik.
Die Eigenschaften haben die Form `cloud.<group>.<native-id-in-lowercase>.reported_evidence`; etwa `cloud.authentication.eidsca.af01.reported_evidence`. Der vollstaendige Originaltext bleibt unveraendert; enthaltene Markdown-Tabellen werden als Originalbelege mitgefuehrt. Metadaten nennen `informationKind: reported-test-evidence`, `inventoryComplete: false`, Regel-ID, Quellstatus und Mapping-Version. Dies sind die vom jeweiligen Test berichteten Informationen, **kein vollstaendiges Konfigurationsinventar** und keine Ermittlung der effektiv wirksamen Richtlinie. Ein nativer Teststatus allein erzeugt keinen Konfigurationswert. Nicht ausgefuehrte/fehlerhafte Tests mit Detailtext bleiben `not-assessed`; fehlende Nachweise werden nicht zu `false` oder `0`.
Exakte freigegebene IDs der Version 1.0.0:
- Authentication: EIDSCA.AF01-06, AG01-03, AM01-04, AM06-07, AM09-10, AP01, AS04, AT01-02, AV01 (jeweils Praefix EIDSCA.).
- Conditional Access: MT.1001, MT.1003-1021, MT.1035, MT.1036, MT.1038.
- Rollen: MT.1025-1032; CISA.MS.AAD.7.1-7.6.
- Zustimmung: EIDSCA.AP08-10, CP01, CP03-04, CR01-04; CISA.MS.AAD.5.1-5.4.
- Externer Zugriff/Sharing: EIDSCA.AP04, AP07, ST08, ST09; CISA.MS.SHAREPOINT.1.1-1.3, 2.1-2.2, 3.1-3.3.
Die genaue Liste steht deklarativ im Importmodul; Titel-/Aehnlichkeitssuche erweitert sie nicht. Quellpruefung 2026-10-04: offizieller [Maester-Testkatalog](https://maester.dev/docs/tests/), exemplarisch [EIDSCA.AF01](https://maester.dev/docs/tests/EIDSCA.AF01/), [MT.1005](https://maester.dev/docs/tests/MT.1005/) und [MT.1032](https://maester.dev/docs/tests/MT.1032/). Offline-Zusammenfassung der fachlichen Zuordnung: Der Katalog ordnet diese IDs Tests fuer Authentifizierungsmethoden, Conditional Access, privilegierte Rollen, Zustimmungseinstellungen und Gast-/Sharing-Einstellungen zu. Er garantiert keine vollstaendige Konfiguration im Export; allein die tatsaechlich enthaltenen Originalwerte sind massgeblich. Kein Onlineaufruf ist fuer Import oder Abfrage erforderlich.
Das Maester-Profil wird auf 1.2.0 und der HTML-Extractor auf 1.2.0 angehoben, der Tabellen-Extractor startet mit 1.0.0. Der historische Verarbeitungsmanifest bleibt erhalten, der Vergleichsschluessel unveraendert. Fruehere Maester-Importe empfehlen regulaeren Reimport. Es erfolgt kein automatischer Kunden-Reimport. Supported Maester reports enthalten weiterhin keinen nutzbaren nativen numerischen Overall Score; der deklarierte Grund und die gemeinsame Fallback-Scoringlogik bleiben unveraendert.
## Verifikation und verbleibende Grenzen
### Konkrete Cloud-Uebersicht (2026-10-04, 2.14.2)
Nutzerkorrektur: Die dauerhaft sichtbare Cloud-Kachel zeigt je Bereich einen belegten Istwert statt Verfuegbarkeitshinweisen. Weitere Fakten und Originale bleiben in Results. Fehlende, nicht ausgefuehrte oder nicht eindeutig erkennbare Angaben erscheinen als `Not reported`. Konflikte werden genannt; mehrere Scan-/Tenant-Scopes werden nicht zusammengerechnet. Passed/Failed, Empfehlungen und generische Scores erzeugen keine Konfiguration.
Die reine Core-Normalisierung `cloudInformationFacts` projiziert bereits gespeicherte Maester-Testdetails. Lesevertrag **1.1.0** ergaenzt `cloudFacts` fuer den gesamten gefilterten Scope vor Pagination: kanonische Property, Original-/Anzeigewert, `reported-setting`/`reported-subset`, Beobachtungs-ID, Scanlauf, Datei, Regel-ID, Quellposition, Quellzeit und Mapping-Version. Engines lesen weiterhin ausschliesslich den veroeffentlichten read-only Port, ohne interne Module zu importieren. Propertyfilter beziehen sich weiter auf die erhaltenen Beobachtungs-Properties; `cloudFacts` enthaelt nur Fakten dieser gefilterten Beobachtungen. Es gibt keine DB-Schreibvorgaenge oder Schemaaenderung. Bereits erhaltene passende Nachweise profitieren ohne Reimport; fehlen die in 2.14.0 eingefuehrten Nachweise, ist weiter ein regulaerer Reimport erforderlich. Profil-/Extractor-Versionen und nativer Scorevertrag bleiben unveraendert.
Offline-Mapping 1.0.0, Quellpruefung 2026-10-04:
- EIDSCA.AF01/AM01/AT01/AV01: State von FIDO2, Authenticator, Temporary Access Pass und Voice. AP08/CR01/AP10: Consent-Policy, Admin-Request-State, App-Registrierung. AP04/AP07/ST08/ST09: Gast-Einladungen, Gastrolle, Gruppenbesitz/-Zugriff. Exakte native erste Saetze liefern den berichteten Wert, einschliesslich ausdruecklich nicht konfigurierter Angaben und des kombinierten Ist-/Sollwertsatzes. Alternativ ist nur eine eindeutige zweispaltige `Setting`/`Actual Value`-Tabelle fuer das exakt benannte Feld erlaubt, nie `Expected Value`.
- CISA.MS.AAD.7.4: exakte Tabelle `Role / Principal Type / Display Name / Status` mit `No Expiration` oder expliziter Satz ueber keine entsprechenden Zuweisungen. MT.1025-1028: Anzahl exakt berichteter Listenzeilen fuer den jeweiligen Principal-Typ. Diese Zahlen beschreiben nur die berichtete Pruefteilmenge. MT.1031: expliziter PIM-Management-Satz; derselbe Helper-Satz unter MT.1032 erzeugt keine PIM-Aussage.
- MT.1007: eindeutige berichtete All-User-MFA-Policy-Links oder expliziter Satz ueber keine entsprechende Policy. Keine Gesamtzahl aktiver/Report-only-Policies. CISA.MS.SHAREPOINT.1.1: nur ausdruecklicher `Current setting`-Wert.
Offizielle Quellen: [EIDSCA-Generator AF01](https://github.com/maester365/maester/blob/main/powershell/internal/eidsca/Test-MtEidscaAF01.ps1) und entsprechende AP08/AP07/CR01-Generatoren im selben Verzeichnis; [permanente Rollen](https://github.com/maester365/maester/blob/main/powershell/public/cisa/entra/Test-MtCisaPermanentRoleAssignment.ps1), [privilegierte Zuweisungen](https://github.com/maester365/maester/blob/main/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1), [PIM-Alerts](https://github.com/maester365/maester/blob/main/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1), [All-User MFA](https://github.com/maester365/maester/blob/main/powershell/public/maester/entra/Test-MtCaMfaForAllUsers.ps1), [Graph-Markdown](https://github.com/maester365/maester/blob/main/powershell/internal/Get-GraphObjectMarkdown.ps1), [SharePoint Sharing](https://github.com/maester365/maester/blob/main/powershell/public/cisa/spo/Test-MtCisaSpoSharing.ps1). Microsoft beschreibt die bekannten [Consent-Policies](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent) und [Gastrollen](https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions). Das Mapping kuerzt deren Anzeige; exakte Originalwerte bleiben im Vertrag und in Results. Keine Online-Abfrage zur Laufzeit.
Synthetische Tests decken alle drei erweiterten Formate, Originalwerte/Tabellen/Quellpositionen, unbekannte IDs, fehlende/bare Statusnachweise, Error/Skipped, false/0/Leerwerte, Konflikte, Pagination/Links, Mandanten-/Timeslotgrenzen, explizite Historie, konkurrierende Revisionen, read-only PostgreSQL-SQL, HTTP-Validierung und den tatsaechlichen Client-Info-Button ab. UI-Pruefungen umfassen beide Themes, Apply/Reset, Fehler/Retry, spaete Antworten und ausgeschaltete Engines. Keine Kundenberichte oder Kundendaten wurden fuer diese Implementierungspruefung gelesen.
Filterung erfolgt derzeit ueber den vorhandenen scoped Snapshot im Backend. Es gibt keine SQL-Filterindizes oder serverseitige Stream-Pagination; sehr grosse Scopes koennen deshalb trotz begrenzter Antwortseiten Arbeitsspeicher beanspruchen. Vollstaendige Cloud-Inventare aus weiteren echten Exportstrukturen erfordern eine gesonderte, quellengestuetzte Profilerweiterung und bleiben offen.

View File

@ -1,5 +1,409 @@
# ASSESSOR – Umsetzungsplan
## Management Report text revision (2026-10-04, Phase 7)
Explicit user request: the report repeated the same generic sentences in every row and theme. Report Engine only (no Core change): renderer 3.3.0, Business Impact mapping 2.1.0 with fuller theme impact text, new versioned `management-report-texts.ts` 1.0.0 with a German name, Sachverhalt, Einordnung and Ansatzpunkt per Context category, written from the catalog conditions of each category. Theme sheets get a data-derived Kernaussage and per-category blocks; the Summary gets data-derived statements; disclaimers appear once in Lesehinweise und Aussagegrenzen; the 38-row observation table becomes one count/date-range sentence; the redundant Findings row is replaced by the deviation rate; Base Severity stays out of theme sheets as before. Permanent test: every category has complete text, no scanner names, the proof disclaimer occurs once. Reviewed with a local Dummy rendering from the stored Context result. Reporting and Context tests pass (126). Shipped within 2.16.4.
## Business Impact - independent Work filter and clickable bars (2026-10-04, Phase 7)
Current user correction: Business Impact must have its own selection, never select or replace the Context Cluster filter. The four Client Info columns open Work with the independent `report:business-impact` facet. The same four filters appear underneath Security Domains in the Work sidebar, with counts, a persistent selection marker and a second click to clear that theme. Existing Context selection is preserved on chart navigation and on sidebar selection; normal filter intersections still apply. Context Cluster remains directly before Source: Scan. Unassigned remains omitted. These are authorized Core Presentation extension points; no evaluation, verdict, source data or stored client data changes.
The facet reads the same published counting keys and theme assignments as the existing report/chart, not every category attachment. This excludes category attachments without published deviation evidence, which direct Dummy verification found could inflate Control & trust by three. The Context index publishes the existing immutable report projection alongside its unchanged cluster facet. Optional engines hide the extension; disabling Report clears only Business Impact, while disabling Context clears both derived selections. Synthetic exact-interaction acceptance in Dark/Light covers clicking a chart, visible separate dropdown selections, retaining a selected cluster, sidebar toggle, exact matching records, input immutability, optional-engine behavior and an attachment without deviation evidence. Patch 2.16.5 is the backward-compatible release for this refinement; final activation and full verification are recorded below. No phase is marked complete.
Verification completed: 1,909 tests passed, 15 optional skips; local-only and design checks passed. Both production builds passed, with the existing bundle-size advisory. Product package/lockfile and UI/API version contracts agree on 2.16.5. Existing report-export test expectations were synchronized with the already changed heading and count-column position, without changing report text. Direct Dummy interaction in both themes confirms 221 / 63 / 121 / 69 in chart, sidebar and Work, Context stays All on theme selection, and clearing Resilience preserves an explicitly selected Detection Blind Spots cluster. Screenshots saved outside the repository. The canonical backend was restarted from the verified bundle; health confirms 2.16.5, PostgreSQL and unchanged revision 540. No report was generated or stored during these interactions.
## One finding, one category - Context and Business Impact (2026-10-04, Phase 13E / Phase 7)
Explicit user decision supersedes multiple cluster membership in counts and the six overlapping Business Impact themes: a deviation is never counted twice. Context Engine 1.11.0 keeps exactly one primary cluster per finding and GPO setting in `clusterIds` (order: catalogued before recognized before estimated, then most supporting evidence, then base severity, then ID); further clusters stay traceable in `secondaryClusterIds`. Catalog 1.10.0 declares `primaryClusters` for all eight conditions several clusters list (ESC8 -> AD CS Escalation; CIS Printers -> Host Credential & Privilege; delegatable admins -> Kerberos Delegation Abuse; privileged SPN and service-account policy -> Kerberoasting; password-never-expires rules -> Password Guessing); catalog validation rejects a shared condition without one. The heatmap counts each deviation only in its primary cluster, so its cells add up to the unique finding total; cluster condition states are unchanged. Report mapping 2.0.0 has four disjoint themes (Kontrolle & Vertrauen, Informationen & Daten, Betriebsfähigkeit, Widerstandsfähigkeit & Wiederanlauf); every category belongs to exactly one, AD Reconnaissance to Informationen & Daten. Financial and compliance consequences are one prose note, never a counted theme. Dummy with the stored context: heatmap 474 = 474 unique (previously 499); themes 221 / 63 / 121 / 69, plus 3 deviations without a Context category = 477. Permanent tests: heatmap sum equals unique count, missing primary cluster is a catalog error, every category in exactly one theme. Client Info (released Presentation, Core Tests, Platform): the Business Impact row has four equal columns in theme order with English labels; its permanent test checks four bars, labels and no Financial/Compliance bar in both themes. Verified in the browser with Dummy: 4 columns, 221 / 63 / 121 / 69, no overflow. Shipped as patch 2.16.4 (the pending, not yet documented working-tree version); full npm test passed with 1,908 tests and 15 optional skips, client and server production builds passed. Open: the 3 deviations without a Context category are not yet explained. No phase is marked complete.
## Client Info - possible Business Impact columns (2026-10-04, Phase 7)
User-authorized Presentation/Platform/Core Tests refinement: six vertical columns below Security Domains show the existing deterministic primary Business Impact distribution, with English labels and counts. The user explicitly excludes the unassigned column here. The total and Security Domains remain authoritative and unchanged; financial/compliance possible consequences remain available in the report even when their primary counts are zero. The Report Engine counting-key helper is shared with the live Client Info workspace; current source selection limits the population without changing Context assignments. This is a pinned read-only presentation extension to published Context and Report results, covered by architecture tests. No external service, model, database write, score change or historical report rewrite is introduced.
Verified with the actual Dummy Client Info, Scan All: 121 / 0 / 57 / 221 / 0 / 69; six columns only, no unassigned label, Core total still 477. Actual Light rendering inspected and saved outside the repository; synthetic interaction tests cover both themes, optional-engine toggles, request reuse, input immutability and unchanged Core presentation. Full npm test: 1,906 passed, 15 optional skips; local-only and design checks passed. Both production builds passed with the existing bundle-size advisory. Backward-compatible presentation refinement released as patch 2.16.4; package/lockfile and UI/API version contracts synchronized. The identified existing backend was restarted using the canonical PostgreSQL configuration; health confirms 2.16.4 and unchanged revision 540. Report renderer remains 3.2.3. Excluding unassigned means the six displayed values sum to 468 rather than changing the 477 total; this is the explicit requested display decision. No phase is marked complete.
## Management Report - exclusive finding distribution (2026-10-04, Phase 7)
Explicit user correction supersedes overlapping counts in the Business Impact overview: its table and chart must distribute all 477 Dummy deviations exactly once. Report-only mapping 1.1.0 assigns a category's direct consequence to its main theme; financial/compliance downstream consequences remain in the possible-impact prose. Conflicting or missing primary assignments stay in Nicht eindeutig zugeordnet, with no arbitrary tie-break. The authoritative summary workspace additionally publishes the exact deviation counting keys; only these keys enter named bars. Stored Core data, verdicts, scores and Context associations remain unchanged. Complete Context evidence stays in theme details and the technical report. No phase is marked complete.
Authorized direct Dummy verification uses all 477 current Work deviations at revision 540 and the corresponding archived Context publication: Betriebsfähigkeit 121, Finanzielle Auswirkungen 0, Informationen & Daten 57, Kontrolle & Vertrauen 221, Compliance & Haftung 0, Widerstandsfähigkeit & Wiederanlauf 69, Nicht eindeutig zugeordnet 9; sum exactly 477. Synthetic acceptance covers overlapping technical evidence, conflicting primary themes, unmapped findings, out-of-scope keys, order independence, unchanged inputs and equal table/chart totals. The corrected Dummy preview is saved outside the repository in the task's visualization directory. Product/backend activation awaits a current Platform/Core Tests release.
Release completed after current Platform/Core Tests consent: patch 2.16.3 and renderer 3.2.3, package/lockfile and UI/API version contracts synchronized. Full npm test passed with 1,904 tests and 15 optional skips, including the actual export-button interaction checking that chart counts sum to the full unfiltered population. Architecture, Core/Context/Report invariance, local-only and design checks passed. Both production builds passed. The identified backend was restarted from the verified bundle; health confirms 2.16.3, canonical PostgreSQL and unchanged revision 540. The regular report-run API generated Dummy archive 20261004T160231Z-5340b9c2; independent extraction verifies all seven table counts sum exactly to 477 and agree with the diagram. The final API HTML is saved as the reviewable Dummy artifact outside the repository; no historical report or real customer report was rewritten. No phase is marked complete.
## Context heatmap - total beside heading (2026-10-04, Phase 7 / 13E)
Explicit user refinement of the compact header: show `Findings: <count>` directly beside `Context heatmap`, aligned on the text baseline. The existing Context Engine's unique heatmap-finding count is reused; overlapping category membership never increases the total. The scope subtitle and legend remain absent, and the reduced top spacing remains intact. Only Context Engine component, styles and acceptance tests changed; no Core extension point, count contract, report or stored data is modified. Synthetic scope-change, overlap, empty-state and click-path tests pass in both themes: 20 focused tests, including Core/Context invariance. Design-language check passed. Cosmetic display refinement only, no separate product release or backend restart; concurrent work is preserved.
## Report wording refinements (2026-10-04, Phase 7)
Counting correction (Phase 7 / 13E): published Context cluster evidence previously exposed multiple technical result IDs per rolled-up GPO setting; the theme union incorrectly counted those IDs as individual findings. The publication now additionally provides immutable counting keys (scanner occurrence and GPO scope/setting identity). Report themes union those keys, preserving all original result IDs and cross-theme membership. Summary reads the shared Core workspace summary with current GPO evaluations through an additional existing read-only repository method, instead of historical GPO evaluation row counts. No verdict, stored data, Core count or scoring changes. Synthetic tests reproduce 482 evidence rows for 477 unique findings, check overlapping themes and the displayed table/chart/summary, and preserve inputs. Read-only verification of Dummy at revision 540 reproduces the exact screenshot: previous theme counts 193/305/482/294/112/208 become 167/279/399/229/112/190. Context currently maps 474 unique findings; the user's 477 total is not substituted by that mapped count in Summary. Initial backend activation and product release awaited current Platform/Core Tests consent.
Release verification: subsequent current Platform/Core Tests consent authorizes patch 2.16.2 and renderer 3.2.2, including the preceding wording refinements. Full suite: 1,903 passed, 15 optional skipped. The final workspace-summary integration passed 76 focused Report/Context, export-click, architecture and UI/API version tests. Direct canonical read-only Report execution confirms 477 deviations / 759 assessed at unchanged revision 540, and a new Dummy report was archived successfully; historical archives remain unchanged. No real customer report was generated. The first compiled API export check returned a generic internal error; direct source runtime and archive verification succeeded after the final summary integration. Both final production builds and backend API activation are checked before reporting release complete. No phase is marked complete.
Final activation verified: both production builds passed after the final summary integration, and the identified backend was restarted with that bundle against the canonical PostgreSQL datastore. The regular report-run API successfully generated and archived Dummy run 20261004T153811Z-0ec39826 at unchanged repository revision 540. Its Summary states 477 deviations / 759 assessed, the six theme counts are 167/279/399/229/112/190, and all user-removed notes are absent. This resolves the earlier compiled-export verification failure. Existing HTML exports remain immutable and must be regenerated to display this release.
Further screenshot correction: remove the technical sentence about findings without a cluster assignment from Summary. Underlying assignments and appendix data remain unchanged; the synthetic renderer test permanently checks that the sentence is absent.
The subsequent screenshot correction also removes the count/overlap note beneath the theme bar chart. The diagram and its counts remain unchanged; synthetic rendering verifies the note is absent.
Another explicit screenshot correction removes the automatic-assignment/specialist-review note from each theme sheet. Conditional impact wording and underlying confidence data are preserved; a permanent synthetic renderer assertion checks the sentence is absent.
Explicit screenshot corrections: remove the technical overlap/absence-of-evidence note beneath the Business Impact overview, use Summary for both first-sheet summary labels, present Handlungsfelder as a heading with bullet points for evidenced themes, and replace Blätter with Angaben in the introductory prose. Counting, absent-evidence handling and stored data remain unchanged. These small Report Engine presentation refinements do not create a separate product release. Fourteen synthetic renderer tests pass, including both Summary labels, the omitted note, the exact evidenced-theme list and the revised introductory wording. The local synthetic preview is regenerated; production backend activation remains separate from source/preview changes. No phase is marked complete.
## Management Report - ASSESSOR overview and confidentiality (2026-10-04, Phase 7, 2.16.1)
Explicit user correction: the report presents ASSESSOR only, with no scanner names or source breakdowns. Management appears only in the repeated page header; every header and footer states Streng vertraulich. Evidence columns show plain finding counts, and the theme overview includes a bar chart of those counts. These presentation requirements supersede earlier visible attribution and confidence breakdowns; retained source data and published Context assignments remain unchanged. Possible Business Impact stays conditional, with no impact grades or amounts. Generation remains entirely offline, without Internet or LLM.
The first sheet now displays the existing ASSESSOR Overall Score as a full-circle SVG donut beside the summary. It reads the shared report projection directly without changing Core scoring: zero is valid, and absent or invalid scores show unavailable rather than an invented zero. Theme counts deduplicate findings within each theme; overlapping themes are never summed into a total. Historical report archives remain unchanged. Renderer 3.2.1 and the binding report template record the refinement.
Verification: full npm test passed with 1,901 tests and 15 optional skips; architecture, local-only, engine invariance and design checks passed. Permanent tests cover the actual report-export click, score rendering including zero and missing values, scanner-name suppression, header-only Management wording, confidentiality, count-only tables, chart counts and input immutability. Both production builds passed after the final diagram placement, as did the UI/API version contracts. A synthetic production-renderer preview was inspected locally; no customer report was generated or replaced. Current Platform and Core Tests consent authorized patch release 2.16.0 -> 2.16.1 and backend activation. Health confirms 2.16.1, PostgreSQL and unchanged repository revision 540. Concurrent Client Info changes are preserved; no phase is marked complete.
## Client Info - remove standalone trigger and simplify heatmap header (2026-10-04, Phase 7)
Explicit user correction with current Presentation and Core Tests consent: remove only the Client Information button from the Dashboard action row, retaining its data, API and standalone browser. Management Report and Technical Report remain blue. The Context heatmap header now contains only its title: remove the scope subtitle, total-findings display and legend. Existing category colors, per-tile findings, scope selection and exact Work navigation remain intact. Reduce the heatmap top margin from the shared space-8 to space-6 token (32px -> 24px); removing the subtitle also moves the grid upwards without changing the shared outer panel inset. This supersedes the preceding three-button and heatmap-header display agreements only for these visible elements. Twenty-eight synthetic tests passed across both themes, including retained standalone browser behavior, scope changes, Work navigation and Core/Context invariance; design-language checks passed. Cosmetic scope only: no separate product release, backend restart or customer-data operation. Concurrent Report Engine work is preserved.
## Client Info - blue action buttons (2026-10-04, Phase 7)
Explicit user colour-role exception with current Presentation, Platform and Core Tests consent: Client Information, Management Report and Technical Report use the same theme-aware blue background, text and border tokens as Engines, including hover and active states. A dedicated class limits the shared CSS rule to these three actions; geometry, engine activation and report behavior remain unchanged. Synthetic Dashboard interactions cover both themes and preserve disabled report buttons when Report Engine is off. Four focused Client Info tests and the design-language check passed. This small cosmetic refinement does not create a separate product release or change the shared product version. No backend restart or customer-data operation; concurrent Management Report work is preserved.
## Management Report - possible business impact (2026-10-04, Phase 7, renderer 3.2.0)
Explicit user decision: replace the category-first management reading with the six proposed management themes, labelled exclusively **Möglicher Business Impact**, with no impact grades, money amounts or actual client-impact assessment. Report generation must use no Internet or LLM. This supersedes the earlier content sequence and prohibition on business-consequence wording only for clearly conditional possible consequences; the approved export layout, Context evidence and all Core disclosure/verdict boundaries remain binding.
Implemented within Report Engine: versioned report-only mapping 1.0.0 from published Context category IDs to Betriebsfähigkeit, Finanzielle Auswirkungen, Informationen & Daten, Kontrolle & Vertrauen, Compliance & Haftung and Widerstandsfähigkeit & Wiederanlauf. Fixed German text describes possible consequences and action guidance only when corresponding evidence exists. Each theme contains observation, action, attribution, confidence and limits. Themes without evidence state no derivable impact; unknown or unmapped categories stay visible in the appendix. Counts deduplicate published result IDs within each theme and never add overlapping themes into a total. Summary and management overview precede six theme sheets; complete Context heatmap and method/technical domain appendix remain. Context Base Severity never becomes an impact rating. Core data, Context assignments and historical report archives are unchanged. The revised binding template records this user decision.
Verification: 44 focused Report Engine/export-button tests passed, including deterministic generation with network access stubbed to fail, overlap counting, empty/unknown categories, confidence, disclosure and input/storage invariance. Full `npm test`: 1,890 passed, 15 optional tests skipped; architecture, local-only, engine invariance and design checks passed. Client and server TypeScript checks passed. Final report-only table-width refinement passed all six affected renderer/export tests. A synthetic production-renderer preview was generated and inspected locally; it contains no customer data or remote assets. No customer report was generated or replaced. No phase is marked complete.
Remaining release step: Report renderer version advances 3.1.1 -> 3.2.0. Product-version reassessment recommends minor 2.15.2 -> 2.16.0 for this backward-compatible management-report capability. Package/lockfile, Core UI/API version tests, production builds and activation in the running backend await a current **Platform, Core Tests** release, as required by AGENTS.md; those Core files and the running backend were not changed for this request. Product version remains 2.15.2 until that release step is authorized and verified. Concurrent workspace changes are preserved.
Release completed (2026-10-04): the user's subsequent current **Platform, Core Tests** release authorized the remaining step. Minor product version 2.16.0 is synchronized in package/lockfile and Core UI/API version contracts. All 54 focused version, HTTP, Report Engine and export-button tests passed; both production builds passed, with the existing bundle-size warning only. The identified local backend was restarted with the verified bundle; health confirms 2.16.0, PostgreSQL and unchanged repository revision 540. The backend bundle contains renderer 3.2.0 and the new Business Impact template. The synthetic local preview was regenerated and returns HTTP 200 with version 2.16.0. New Management Reports use the new template; no customer report or historical archive was generated or replaced during release verification. The entire earlier report verification remains recorded above; no phase is marked complete and concurrent Client Info work is preserved.
## Data Hub - returning catalog versions update correctly (2026-10-04, Phase 7, 2.15.2)
Verified correction under current Reference Catalogs, Presentation, Platform and Core Tests consent. Reference recognition previously skipped a historical run with the same deterministic identity even when a newer run used different catalogs, leaving the status permanently pending after A -> B -> A. Reprocessing now checks the latest run's input, recognizer version and catalog hashes for unchanged work. Returning to a historical combination creates a new immutable reference run with a timestamp newer than previous runs and a distinct identity. Historical runs, source findings and reviews remain unchanged; no schema or repository contract changes are required. The existing detail panel explicitly reports an incomplete update when the subsequent status still contains pending scans, retains retry and only reports success when pending work is zero. No visual layout or theme change.
Verification: permanent synthetic service coverage includes repeated A -> B -> A transitions, a backwards-moving clock, unchanged retries and preserved historical runs/findings/reviews. Permanent click-path tests execute the real service through the API client boundary in both themes; incomplete-update feedback remains covered. All 26 focused tests passed. Full `npm test`: 1,888 passed, 15 optional tests skipped; local-only, architecture, engine invariance and design checks passed. Both production builds and UI/API version contracts passed. Patch 2.15.1 -> 2.15.2 records this backward-compatible correction; existing bundle-size warnings remain. The identified local backend was restarted from 2.15.0 to 2.15.2 against the unchanged canonical PostgreSQL datastore. LSW's regular reference-update API processed its one remaining pending scan after restart: 3 indexed scans, 0 pending. A second call returned 0 processed and 3 unchanged. This verification appended reference metadata only, with no reimport or destructive operation. Concurrent edits are preserved and no phase is marked complete.
## Data Hub - pending catalog references marked red (2026-10-04, Phase 7, 2.15.1)
Explicit user correction with current Presentation, Platform and Core Tests release: pending Catalog references recognition is important actionable maintenance and marks the client overview dot and Manage button red, including before client selection. This supersedes the earlier yellow overview treatment of pending references. A dedicated `reference-update` presentation state retains the reference-update explanation instead of classifying pending work as a processing failure. It takes precedence over yellow profile/re-evaluation/review notices; real processing errors retain priority and the tooltip preserves both causes. After successful recognition, the remaining processing state determines the color. The existing Catalog references detail panel and its Update references action stay intact; scanner outcomes, scores, imports and stored data are unchanged. No actual customer reference update was executed.
Verification: permanent synthetic DataHub interactions cover Dark/Light, initially unindexed and previously indexed outdated references, Manage/update/back, successful clearing, failed update and retry, status retry, catalog/import refresh and error priority. An additional check preserves a concurrent yellow profile-update task after reference recognition. Browser verification with synthetic clients confirms red pending-reference dots/Manage in both themes and green/red/yellow remaining states after simulated completion. All 24 focused tests passed; full `npm test`: 1,885 passed, 15 optional tests skipped. Architecture, engine invariance, local-only and design checks passed. Both production builds and UI/API version contracts passed; scoped diff whitespace checks clean. Patch 2.15.0 -> 2.15.1 records this backward-compatible UI correction. Existing bundle-size and unrelated React-key warnings remain. Concurrent edits and historical decisions are preserved; no phase is marked complete.
## Security Domains - strukturierte Zuordnung (2026-10-04, Phase 7 / Evaluation; umgesetzt, 2.15.0)
**Umsetzung und Verifikation (2026-10-04).** Die Umsetzung erfolgte mit Core-Freigabe `Evaluation, Ingest, Reference Catalogs, Presentation, Core Tests, Platform, Governance`; die Fortsetzung mit erneuter Freigabe `Evaluation, Ingest, Presentation, Core Tests, Platform`. Der Hook hatte die erste Freigabe nach einer Hintergrund-Benachrichtigung verworfen.
Umgesetzter Stand:
- **Klassifizierer:** ein einziger in `src/evaluation/security-domains.ts`, Version 2.0.0. Er liest nur Raw-Data-Felder; ein Werkzeug-Hinweis über die `toolId` des Blatts gilt für Einzelblätter ohne `Source: Scan`.
- **Zuordnungstabellen:** versioniert je Profil, in `src/profiles/{cis-cat,group-policy,maester,pingcastle,purple-knight}/*-security-domains.ts`. Maester akzeptiert auch die Feldnamen `Block`, `ID` und `Tag` (JSON-Listen).
- **Abgeleitete Raw-Data-Felder:**
- `Benchmark section` für CIS-CAT (in `canonical-model-to-dataset.ts`).
- Für Security-Template-Einstellungen ohne Vergleich: `Policy section`. Gelesen wird er aus dem ASSESSOR-eigenen Beobachtungstitel `<identity.section> · <identity.key>`, nur bei Übereinstimmung aller Quellen.
- Für Security-Template `Registry Values`: `Registry path`, aus der Vergleichsidentität bzw. demselben Titel. Dieser Fall ergab sich erst aus der Dummy-Prüfung und betrifft 49 Einstellungen.
- Offen: ein eigenes Projektionsfeld für Abschnitt und Schlüssel wäre sauberer. Es bräuchte Freigabe für Normalization und Disclosure.
- **Titelerkennung:** Sie ignoriert `Benchmark section` als Titelquelle (`record-builder.ts`).
- **Report:** `domainOf` nutzt den Work-Datensatz, über die Occurrence bzw. Scope und Technical Identity. Ohne Work-Datensatz greift der Klassifizierer auf die strukturierten Ergebnisfelder zurück.
- **Bereinigung:** Die `Unclassified`-Zweige in Client Info, Sidebar und Facet sind entfernt. Die Vereinbarungen in AGENTS.md sind ersetzt.
- **GPO-Tabelle:** Der Eintrag `File Security` ist entfallen, weil die Local-only-Prüfung das Wort `File` sperrt; er ergab dieselbe Domain wie die Auffangstufe.
Verifiziert:
- **Tests:** 13 neue synthetische Klassifizierer-Tests (eine Stufe je Test, keine `Unclassified`-Domain, Summe gleich Gesamtzahl, Unabhängigkeit vom Ergebnis, Werkzeug-Hinweis, Report-Rückfall). Dazu ein neuer Test, dass Report und Work für Scanner- und GPO-Ergebnisse dieselbe Domain liefern. Zehn bestehende Testdateien wurden an die neue Vereinbarung angepasst, darunter der dauerhaft beibehaltene Klickpfad-Test Client Info → Work-Facet.
- **Testsuite und Prüfungen:** `npm test` mit 1.882 bestandenen und 15 übersprungenen Tests; die Local-only- und Designprüfung bestanden.
- **Builds:** Beide Produktionsbuilds bestanden; die bekannte Warnung zur Bundle-Größe bleibt.
- **Version:** UI und API melden 2.15.0.
- **Browserprüfung mit dem freigegebenen Dummy:**
- Client Info zeigt sechs Domains mit den Abweichungen Identity 112, Privileged 54, Infrastructure 19, Hardening 245, Cloud 7, Monitoring 40.
- Der Klick auf System Hardening setzt das Work-Facet auf `hardening` mit 245 Datensätzen und Status `Deviation`.
- Es gibt kein `Unclassified` und kein neues Facet `Benchmark section`.
- **Dummy-Verteilung nachher** (alle 1.833 Datensätze): Identity 302, Privileged 152, Infrastructure 58, Hardening 1.077, Cloud 149, Monitoring 95.
- **Herkunft der Zuordnungen:**
- CIS: 411 über Stufe 2, davon 87 Pass und 323 Fail auf verschiedene Domains verteilt, statt 187 bei Identity.
- Maester: 317 über die Stufen 1/3, 1 über Stufe 5.
- PingCastle 37 und Purple Knight 119 über Stufe 1.
- GPO: 880 über Stufe 4, 68 über Stufe 5.
Versionsentscheidung: Minor 2.14.3 → 2.15.0, weil sich die sichtbaren Domain-Kategorien und Domain-Pass-Raten ändern. Package, Lockfile und die Versionsverträge für UI und API sind synchronisiert. Historische Reports behalten ihre Domains. Für diese reine Neuzuordnung gespeicherter Felder ist kein Reimport und kein Verarbeitungsmanifest nötig. Die Phase wird nicht als abgeschlossen markiert.
Ursprünglicher Planungsstand: Bisher war nur die Planung erfolgt, noch kein Code geändert. Der Nutzer hat im Chat zwei Entscheidungen getroffen: **6 Domains** und **zuerst Plan plus Dummy-Vorher-Zahlen**. Die Umsetzung braucht eine eigene Core-Freigabe, die der Nutzer in seiner Nachricht selbst schreibt. Voraussichtlich nötig: `Evaluation, Ingest, Reference Catalogs, Presentation, Core Tests, Platform`. Die Freigabe-Zeile im eingefügten Vorschlag war nur ein Beispiel und gilt nicht als Freigabe.
**Problem (am Code bestätigt).** `src/features/dashboard/security-domains.ts` ordnet Prüfungen über Textmuster zu. Dabei gewinnt das erste passende Muster. Identity steht ganz oben und erkennt schon `user`, `account` und `password`. Die Beschreibung zählt mit, deshalb landet „Group Policy“ als Umsetzungsweg bei GPO. Die Maester-Suiten stehen in einer Feature-Datei statt im Profil. Außerdem gibt es zwei Klassifizierer: `securityDomainIdForRecord` für Work und Client Info, `securityDomainIdForUnifiedResult` für die Report-Projektion. Sie lesen unterschiedliche Felder.
**Entscheidung des Nutzers vom 2026-10-04 (wird mit der Umsetzung verbindlich).** Es gibt sechs Domains:
1. Identity & Authentication
2. Privileged Access & Delegation
3. AD Infrastructure & PKI
4. System Hardening
5. Cloud & Collaboration
6. Monitoring & Recovery
`GPO & Baseline` entfällt als Domain. Die GPO-Sicht bleibt über `Source: Scan = GPOs` und die GPO-Facets vollständig erhalten. Bei der Umsetzung werden diese Vereinbarungen in AGENTS.md abgelöst. Das braucht die Freigabe `Governance`:
- „Security Domain bietet alle acht Katalogeinträge“
- „reine GPO-Sicht = `GPO & Baseline`“
- die acht Knoten der Domain-Spine in Client Info
- die Domain-Pass-Raten im Report
`Unclassified` entfällt.
**Zielmodell.** Es gibt einen einzigen Klassifizierer in `src/evaluation/` für Work, Client Info, Work-Facet und Report-Projektion. Die Zuordnung hängt nur an der Prüfung, nie am Ergebnis. Pass und Fail folgen also denselben Regeln. Jede Prüfung bekommt genau eine Domain. Die erste Stufe mit einem Treffer entscheidet:
1. Native Kategorie bzw. exakte Regel-ID nach der Profiltabelle.
2. CIS-CAT-Abschnittstitel aus der Score-Tabelle desselben Laufs (`cis-benchmark-section-titles.ts`), nie die Nummer allein.
3. Maester-Suite bzw. Kontroll-ID.
4. GPO `Policy section` bzw. `Policy type`.
5. Auffangdomain je Quelle.
Jede Zuordnung trägt die Stufe und das Quellfeld mit. Je Quelle wird ausgewiesen, wie viele Prüfungen in Stufe 5 landen. Es gibt keine Textmuster über Titel oder Beschreibung. Die Tabellen sind versioniert und liegen im Scanner-Profil (analog `securityScoring`). Keine Abhängigkeit von der Context Engine.
**Dummy-Vorher-Stand (heutiger Klassifizierer, Projektblatt, 1.833 Datensätze, nur Zählwerte).**
- CIS-CAT (411):
- Fail: `identity` 120, `gpo` 94, `hardening` 58, `authentication` 23, `infrastructure` 16, `cloud` 5, `privilege` 5, `recovery` 2
- Pass: `identity` 67, `gpo` 8, `authentication` 4, `hardening` 2, `infrastructure` 2, `unclassified` 2, `privilege` 1, `recovery` 1
- Die Härtung ist damit deutlich unterrepräsentiert.
- Maester (~ 330): fast alles `identity` (Fail 61, Pass 68, Skipped 86), nur 8 `cloud`. Das gilt auch für ORCA/Exchange.
- PingCastle (37): `identity` 25, `hardening` 4, `infrastructure` 3, `authentication` 2, `recovery` 2, `gpo` 1.
- Purple Knight AD (~ 120): überwiegend `identity`, 2 `unclassified`.
- GPOs (948): alle `gpo` (Fail 25, Pass 25, `Not configured` 879, `No baseline rule` 19).
**Verfügbarkeit der Quellfakten (Reimport-Prüfung).**
- CIS-CAT: Alle 411 Ergebnisse lösen einen Abschnittstitel desselben Laufs auf. Vorkommende Hauptabschnitte: Account Policies, Local Policies (Security Options, User Rights Assignment), System Services, Windows Defender Firewall, Advanced Audit Policy, Administrative Templates (Computer/User).
- Purple Knight: `Category` ist gespeichert. Werte: Account Security, AD Infrastructure Security, AD Delegation, Kerberos Security, Group Policy Security, Hybrid (AD).
- Maester: `Category`/Block ist gespeichert, ebenso die Regel-ID-Präfixe. Blocks: Maester/Entra, CISA, ORCA, EIDSCA, CIS, Exposure Management, Maester/Exchange, Teams, Intune, AzureConfig u. a. Präfixe: `CISA.MS.AAD`/`EXO`/`SHAREPOINT`, `EIDSCA.*`, `CIS.M365`, `MT.*`, `ORCA`.
- PingCastle: `Category` (Anomalies, Stale Objects, Privileged Accounts, Trusts) wird im HTML- und im XML-Pfad gespeichert, `Model` nur im XML-Pfad. Die Kategorie allein ist für Anomalies und Stale Objects zu grob. Vorschlag: eine exakte Regel-ID-Tabelle im Profil, mit der Kategorie als Rückfall. Dafür ist kein Reimport nötig, weil die Regel-ID immer gespeichert ist.
- GPO: Das Feld `Policy section` ist für Privilege Rights, System Access, Registry Values und Audit-Subkategorien gesetzt. Korrektur derselben Planung: Von den 825 Einstellungen ohne dieses Feld haben 810 einen gespeicherten Registry-Pfad. Das ist das Ziel `key` der technischen Policy-Identität. Die übrigen 15 sind Security-Template-Einstellungen (Kerberos Policy, Privilege Rights). Ihr Abschnitt steht strukturiert in `identity.target.section`. Stufe 4 liest deshalb die technische Identität: Security-Template- bzw. Audit-Abschnitt oder Registry-Schlüssel. Sie liest nicht die Feldbeschriftung oder den Titel.
- Registry-Schlüssel werden über eine versionierte Präfix-Tabelle zugeordnet, der längste Treffer gewinnt. Der Vergleich ignoriert Groß- und Kleinschreibung, die Hive gehört nicht zum Schlüssel. Vorschlag:
- `...\Windows\EventLog`, `...\PowerShell\ScriptBlockLogging`/`Transcription`, Audit → Monitoring & Recovery
- `Control\Lsa`, `Kerberos`, `Biometrics`, `Windows\System` mit Logon-Werten → Identity & Authentication
- `CredentialsDelegation`, `AdmPwd`/`LAPS`, `Services\NTDS` → Privileged Access & Delegation bzw. AD Infrastructure & PKI
- `SystemCertificates` → AD Infrastructure & PKI
- Defender, Firewall, FVE (BitLocker), Office, Internet Explorer, Edge, Tcpip, Netbt, LanmanServer, MrxSmb10, Print und alle übrigen Schlüssel → System Hardening
- Die ADMX-Kategorie (Pfad in den Administrative Templates) ist im ADMX-Katalog nicht gespeichert. Sie würde eine Parser-Änderung und eine Neuauswertung der Baselines erfordern und ist für die Zuordnung nicht nötig.
- Dummy-Verteilung der Schlüssel ohne Abschnitt: Internet Explorer 231, `Policies\Microsoft\Windows` 197, Office 191, Defender 51, Firewall 27 sowie kleinere Gruppen. `Policies\Microsoft\Windows` wird eine Ebene tiefer unterschieden.
**Nutzervorgabe 2026-10-04: Zuordnung grundsätzlich über Raw Data.** Raw Data entspricht `record.fields` mit Wert (`RawDataView.tsx`). Prüfung am Dummy-Projektblatt:
- PingCastle (37): `Source Rule ID` und `Category` sind zu 100 % vorhanden. `Model` fehlt (HTML-Import). Die Regel-ID-Tabelle reicht.
- Purple Knight (119): `Category` und `Source Rule ID` sind zu 100 % vorhanden. Reicht.
- Maester (318): `Category`/Block, `Source Rule ID` und `Tags` sind zu 100 % vorhanden. Reicht.
- CIS-CAT (411): **Lücke.** Raw Data enthält nur `Source Rule ID` (Nummer), `Benchmark Item` und `Control Mappings`, keinen Abschnittstitel. Der Titel steht nur in der Section-Score-Tabelle des Berichts und in der Record-Eigenschaft `benchmarkSectionTitles`. Vorschlag: ein zusätzliches Raw-Data-Feld `Benchmark section` mit dem Titelpfad aus derselben Lauftabelle, ohne Reimport. Alternativ dienen die nativen `Control Mappings` (CIS Controls) als gröbere Stufe.
- GPO (948): `Policy type` 100 %, `Registry path` 85 %, `Policy section` 13 %. **Lücke bei 15** Security-Template-Einstellungen mit `No baseline rule`: Sie haben weder Abschnitt noch Pfad. Abschnitt und Schlüssel stehen nur im erzeugten Titel, `Technical Identity` ist ein Hash. Vorschlag: `Policy section` auch für diese Zeilen aus `identity.target.section` füllen, statt den Titel zu zerlegen.
- `Source: Scan` (Stufe 5) ist überall zu 100 % vorhanden.
Ergebnis: Es ist voraussichtlich kein neues Verarbeitungsmanifest und kein Reimport nötig. Die Zuordnung liest nur bereits gespeicherte Felder.
**Entscheidungen des Nutzers vom 2026-10-04 (zweite Runde).**
- CIS-CAT bekommt das neue Raw-Data-Feld `Benchmark section`. Es enthält den Titelpfad aus der Section-Score-Tabelle desselben Laufs. Bei fehlendem oder mehrdeutigem Titel bleibt das Feld weg, es wird nichts erraten. Stufe 2 liest dieses Feld.
- Entra-Prüfungen (EIDSCA, CISA.MS.AAD, Maester/Entra, Conditional Access, MFA, Identity Protection) gehören zu `Identity & Authentication`. Exchange, Teams, SharePoint, ORCA, Intune und die übrige Tenant-Konfiguration gehören zu `Cloud & Collaboration`.
- Purple Knight `Group Policy Security` gehört zu `System Hardening`.
- GPO: `Policy section` wird auch für Security-Template-Zeilen ohne Baseline-Regel aus `identity.target.section` gefüllt. Das ist ein Vorschlag aus der Raw-Data-Prüfung, den der Nutzer nicht ausdrücklich beantwortet hat. Er ist als Teil der Umsetzung vorgesehen, sofern der Nutzer nicht widerspricht.
Damit sind alle inhaltlichen Fragen geklärt. Offen ist nur noch die Core-Freigabe, die der Nutzer selbst schreibt.
**Risiken.**
- `Not configured`-GPO-Einstellungen verteilen sich künftig auf Domains. Zu prüfen ist, dass sie in Domain-Scores weiterhin score-neutral bleiben.
- Gespeicherte lokale Facet-Auswahlen mit `gpo` oder `unclassified` müssen beim Laden still und sichtbar entfallen.
- Die Report-Pass-Raten ändern sich. Historische Reports behalten ihre Werte.
**Abnahme.**
- Synthetische Tests je Profil, eine Stufe pro Test.
- Kein `Unclassified`. Die Summe der Domains entspricht der Gesamtzahl.
- Gleiche Domain für dieselbe Prüfung in Work, Client Info und Report.
- Der Regressionstest für den Klickpfad Client-Info-Drilldown → Work-Facet bleibt erhalten.
- Dummy-Nachher-Verteilung gegen den Vorher-Stand oben.
- Versionsentscheidung (Minor, weil sich die sichtbaren Domain-Kategorien ändern) nach Abnahme.
## Operating systems - EOL overview removed (2026-10-04, Phase 7, 2.14.3)
Explicit user decision supersedes the earlier EOL overview-field implementation: remove `EOL OS` from the Operating systems tile because the lifecycle-reference effort does not provide enough value. Current Core release covers Presentation, Platform and Core Tests. The tile again uses only its existing source-backed inventory facts, with the existing empty Inventory marker. Original report tables and the Results interaction remain unchanged; no lifecycle catalogue, evaluation, reimport, migration or stored-data change is introduced. Historical decisions and source-verification notes below remain intact.
Verified: permanent synthetic interaction checks in Dark and Light assert that the tile contains no EOL field even when a retained source value exists, preserves inventory facts, opens original Results evidence and leaves the snapshot unchanged. Empty tiles also omit the EOL placeholder in both themes. Browser inspection with synthetic data confirms Dark/Light and the Results interaction. Full `npm test`: 1,869 passed, 15 optional tests skipped; local-only, design, architecture and engine-invariance checks passed. Both production builds, product-version UI/API contracts and scoped diff whitespace checks passed. Existing client bundle-size and unrelated React-key warnings remain. Patch 2.14.2 -> 2.14.3 records this backward-compatible presentation correction; package/lockfile and version contracts are synchronized. No phase is marked complete; concurrent workspace edits are preserved.
## EOL OS - source verification correction (2026-10-04, Phase 7 / 13C)
User reports that EOL OS does not work. Read-only audit found that the preceding implementation and test only accept a hypothetical `EOL OS` column: the synthetic import test explicitly injects that table into the fixture. This proves parser handling, not that a normal PingCastle export supplies the value. Public source verification of `netwrix/pingcastle` master found no EOL aggregate in `ReportHealthCheckSingle.cs`'s computer-account/OS table generator and no direct EOL aggregate in the inspected `HealthcheckData.cs`. Therefore the prior statement that EOL is a supported native overview value is not established and must not be repeated as verified scanner coverage.
PingCastle does publish individual ObsoleteOS rules, for example `S-OS-2012` in `HeatlcheckRuleStaledObsolete2012.cs`; this rule reads OS occurrence counts and respects a paid-support override. Such findings can be displayed as native scanner evidence, but they are not a complete, overlap-free EOL inventory. Missing rule findings do not establish zero EOL systems. Public sources: https://github.com/netwrix/pingcastle/blob/master/PingCastleCommon/Report/ReportHealthCheckSingle.cs and https://github.com/netwrix/pingcastle/blob/master/PingCastleCommon/Healthcheck/Rules/HeatlcheckRuleStaledObsolete2012.cs . No customer report was inspected and no Core file was changed in this audit. The unsupported overview field remains pending explicit current Core consent for its correction; no phase is marked complete. Historical verification notes below are retained and superseded by this source-coverage correction.
## Operating systems - EOL OS field (2026-10-04, Phase 7)
Explicit user request and current Ingest, Normalization, Presentation, Platform and Core Tests release: Client Info's Operating systems tile now reserves its second fact for `EOL OS`, including narrow tiles that display only two facts. It reads an explicit retained PingCastle Computer Information total with an EOL OS/OS EOL/end-of-life OS label. Zero is preserved; missing, malformed, unassessed, contradictory or multiple source totals show the existing unknown marker. No lifecycle judgement is inferred from an OS name or current vendor lifecycle dates, and separate reports/rows are never added. Results now includes the original EOL table alongside the OS inventory, with existing native evidence and source references. The generic importer already retains these fields, so no extractor, mapping, native overall score, persistence, migration or reimport changes were required.
77 focused synthetic import, OS-tile/Results, dashboard, evidence, architecture and UI/API-version tests passed. Permanent interactions cover both themes, the visible second fact, Original Results and unchanged observations; count checks cover zero, invalid, absent and multiple/conflicting sources. Browser inspection confirmed Dark/Light with synthetic data and the native EOL table under Results. Local-only and design checks passed. Patch 2.14.1 -> 2.14.2 records this backward-compatible presentation refinement; package/lock and UI/API version contracts are synchronized. Existing imported reports without an explicit retained EOL total remain unknown; actual customer report coverage was not validated. No phase is marked complete, concurrent changes preserved.
Final verification for the EOL OS batch: both production builds passed, including TypeScript checking of the synthetic fixtures; the final 12-test PingCastle import rerun passed and scoped `git diff --check` is clean. Existing client bundle-size warning remains.
## Cloud Information - konkrete Istwerte (2026-10-04, Phase 13C / 7, 2.14.2)
Nutzerkorrektur mit aktueller Core-Freigabe Ingest, Normalization, Presentation, Platform und Core Tests: Vier identische Verfuegbarkeitshinweise haben keinen ausreichenden Aussagewert. Die dauerhaft sichtbare Kachel zeigt jetzt je Cloud-Bereich einen konkreten, ausdruecklich berichteten Wert, etwa Authentifizierungsmethode, berichtete permanente Rollenzuweisungen, Admin-Consent-Request-State oder Gastrolle. Dies ersetzt die unten historisch dokumentierten Verfuegbarkeitshinweise aus 2.14.1. Fehlende/unassessed/unklare Werte erscheinen als `Not reported`; Konflikte werden sichtbar. Weitere Fakten und komplette Originalnachweise bleiben ueber Results erreichbar. Kompakte Labels und ein Wert pro Feld erhalten die gemeinsame 14px/16px-Typografie und quadratische Kachel ohne Inhalts-Scrollbar in den geprueften synthetischen Beispielen.
Die pure Normalisierung liest ausschliesslich bereits erhaltene Maester-Testdetails mit exakter nativer Regel-ID und ausdruecklichen Istwert-Templates/Tabellen. Passed/Failed oder Empfehlungen liefern niemals Konfigurationswerte. Zahlen werden als berichtete Testteilmengen gekennzeichnet, nicht als vollstaendige Tenant-Inventare. Mehrere Scan-/Tenant-Scopes werden nicht zusammengerechnet, widerspruechliche Quellen nicht aufgeloest. Offizielle Generatoren und Microsoft-Semantik wurden geprueft; Offline-Mapping und Grenzen stehen in `docs/CLIENT_INFORMATION.md`. Keine Onlineabfrage zur Laufzeit und keine Kundenberichte als Testdaten.
Der rueckwaertskompatible Client-Information-Lesevertrag 1.1.0 ergaenzt `cloudFacts` fuer den vollstaendig gefilterten Scope vor Pagination, mit kanonischer Property, Wert, Quellen-/Scan-/Dateibezug, Quellzeit, Scope und Mapping-Version. Future Engines nutzen weiterhin ausschliesslich den veroeffentlichten read-only Port. Keine Persistenz-, Schema-, Finding-, Score-, Result-DB- oder Engine-Aenderung. Bereits erhaltene passende Nachweise profitieren ohne Reimport; fehlen sie, bleibt regulaerer Reimport notwendig. Profil-/Extractor-Versionen bleiben unveraendert, da keine neue Extraktion stattfindet; der bestehende native Maester-Overall-Score-Vertrag bleibt erhalten.
Verifiziert: 23 fokussierte Leseport-/Cloud-/UI-/Importtests sowie 49 Import-/Scoring-/Architekturtests bestanden. Permanenter synthetischer UI-Test prueft vier konkrete Bereiche, Results mit Originaltabelle, Dark/Light, leere dauerhaft sichtbare Kachel und unveraenderte Quellen; die echte Dashboard-Interaktion bleibt abgedeckt. Drei Importformate liefern dieselbe Source-backed Normalisierung ohne veraenderte Finding-Outcomes. Vollstaendiges `npm test`: 1.867 bestanden, 15 optionale Tests uebersprungen; Local-only-/Design-, Architektur-, Disclosure- und Engine-Invarianztests bestanden. Browserpruefung bestaetigt Dark/Light, konkrete vier Felder, Results-Aufruf und leere Kachel. Beide Produktionsbuilds, UI/API-Versionsvertraege und `git diff --check` bestanden. Zwischenzeitliche Typfehler paralleler OS-Workspace-Aenderungen wurden dort korrigiert; anschliessender Build erfolgreich. Bestehende Bundle-Groessenwarnung bleibt; kein Meilenstein neu als abgeschlossen markiert.
Versionsentscheidung: Patch 2.14.2 als Verfeinerung der bestehenden Client-Information-Faehigkeit; die bereits parallel vorbereitete gemeinsame Package-/Lockfile-/UI-/API-Version 2.14.2 wurde beibehalten statt pro Teilbearbeitung weiter zu inkrementieren. Gleichzeitige lokale Arbeiten und historische Entscheidungen bleiben erhalten. Grenzen: ausschliesslich deklarierte native Ausgabeformen; keine pauschale Aussage ueber komplette Cloud-Konfiguration, eligible/permanent Gesamtzahlen oder Report-only-Policies.
## Engine outlines - contrast correction (2026-10-04, Phase 7)
User rejected the first outline as visually indistinguishable from related ordinary tiles and requested explicit Light-mode consideration. The engine identity frame now uses a 2px shared emphasis outline in the theme's neutral secondary-text color, including the left marker: light grey in Dark, dark grey in Light. It no longer shares the faint related-node border color. Blue remains the keyboard-focus role; yellow remains the selected frame and related-arrow role. The generic `engine: true` contract continues to apply to future Engines. This entry corrects the visual acceptance of the preceding implementation without rewriting its history.
Permanent Dark/Light interaction checks now guard the stronger identity outline as well as deselection and unchanged arrow behavior. 24 focused diagram, architecture and UI/API-version tests passed; browser inspection confirmed all three unselected Engine frames in both themes and yellow selection with exactly three highlighted Context connections. Both production builds and local-only/design checks passed. Version remains 2.14.1: correction of the same current UI batch, no independent capability release. No data, persistence, relationship or phase-completion change; unrelated workspace changes preserved.
## Engine outlines - user decision and implementation (2026-10-04, Phase 7)
Explicit user decision applies to every current and future Engine: its Data Flow tile always has a subtle neutral frame. Only selecting the Engine highlights its frame and related arrows in yellow; hovering or deselecting leaves arrows neutral. The shared engine style now supplies the full neutral border, and node descriptors declare `engine: true` instead of relying on a hardcoded list of three IDs. Future Engine descriptors must use this declaration to inherit the same visual contract. Existing initial Core selection, relationships and data flows remain unchanged.
Verified under the current Presentation, Platform and Core Tests release: 24 focused diagram-interaction, UI/API-version and architecture tests passed. Permanent synthetic interaction tests cover all three Engines, hover, selection and deselection in both themes. Browser inspection of the production component with synthetic data confirmed neutral unselected frames and yellow selected relationships in Dark/Light. Both production builds, local-only/design checks and `git diff --check` passed. Existing bundle-size warning remains; no persistence or engine behavior changed and no phase is marked complete. Product version retained at the already prepared 2.14.1 as part of the current shared patch batch; concurrent workspace changes preserved.
## Cloud Information - dauerhaft sichtbare Kachel (2026-10-04, Phase 7, 2.14.1)
Explizite Nutzerkorrektur: Die Cloud-Information-Kachel wird immer angezeigt, auch wenn keine Cloud-Nachweise importiert sind. Dies ersetzt die vorherige bedingte Einblendung als Vorschlag. Nach aktueller Core-Freigabe fuer Presentation, Platform und Core Tests ist die Kachel in der bestehenden Client-Info-Umgebungskachelreihe umgesetzt: vier Felder fuer Sign-in/Conditional Access, Rollen/Berechtigungen, Application consent und externen Zugriff/Sharing. Vorhandene beobachtete Nachweise erscheinen als `Source evidence available`; sonstige vorhandene Nachweise als `Source evidence not assessed`; fehlende als `No retained information`. Test-Outcomes liefern keine erfundenen Konfigurationswerte. Die Kachel verwendet die bestehenden quadratischen Kachelstile, vier Fakten, 14px Labels/16px Werte und den normalen Results-Button.
Results oeffnet den bestehenden Client-Information-Browser mit den vier Cloud-Bereichen und dem exakten Mandanten-/Timeslot-/Scan-Scope. Die Bereichsauswahl und Reset behalten den Cloud-Scope bei. Ohne gespeicherten Mandanten bleibt die Kachel sichtbar und Results zeigt den vorhandenen leeren Quellenhinweis. Scopewechsel schliessen das bisherige Datenfenster durch den Scope-Key. Der allgemeine Client-Information-Button bleibt unveraendert erreichbar. Keine neue Extraktion, Tabelle, Persistenz, API-/Engine-Beziehung oder Datenfluss; Work-Filter und Core-Werte bleiben unveraendert.
Verifikation: vollstaendiges `npm test` erfolgreich, 1.854 Tests bestanden, 15 uebersprungen; Local-only-/Design-, Architektur- und Engine-Invarianzpruefungen bestanden. Permanente synthetische Dashboard-Interaktionen pruefen Dark/Light, immer sichtbare vier Cloud-Felder ohne Daten, beobachtete und nicht bewertete Nachweise, den echten Results-Aufruf mit Originaldetails, Cloud-Filter/Reset, unveraenderte Work-Filter und deaktivierte optionale Engines. Bestehende Kachelanzahlpruefungen wurden um genau die neue Kachel erweitert. Browserpruefung mit synthetischen Daten bestaetigt Dark/Light, leere sichtbare Kachel und leeren Results-Aufruf. Beide Produktionsbuilds und UI/API-Versionsvertraege bestanden; `git diff --check` ohne Fehler. Laufender Backend-Healthcheck: 2.14.1, PostgreSQL.
Versionsentscheidung: Patch 2.14.0 -> 2.14.1 als UI-Ergaenzung des bestehenden Client-Information-Vertrags; Package/Lockfile und Versionsvertraege synchronisiert. Bestehende Bundle-Groessenwarnung und fremde React-Key-Testwarnung bleiben bestehen. Keine Phase als abgeschlossen markiert, keine Kundenberichte gelesen oder reimportiert, gleichzeitige lokale Arbeiten erhalten. Neue Maester-Nachweise erfordern weiterhin regulaeren Reimport; die Kachel behauptet weder vollstaendige Cloud-Konfiguration noch einen Livezustand.
## Client Information - Umsetzung 2026-10-04 (Phase 13C / 7; Version 2.14.0)
Nach expliziter Core-Freigabe fuer Ingest, Normalization, Store, Presentation, Platform und Core Tests ist der unten dokumentierte Auftrag umgesetzt und synthetisch verifiziert. Der neue Core-Lesevertrag `ClientInformationReadPort` 1.0.0 und der HTTP-Zugang `/projects/:id/client-information` erschliessen den vorhandenen Beobachtungs-/Berichtsbestand gezielt nach Mandant, Timeslot, Scanlauf, Bereich, Eigenschaft, Objekt, Scanner und Erfassungszustand. Client Info bietet dafuer den Button **Client Information** mit Filterung, Originalwerten/Tabellen, Provenienz, Details, Pagination und Retry. Optional deaktivierte Engines beeintraechtigen ihn nicht. Bestehende Snapshot-, Finding-, Review- und Exportvertraege bleiben erhalten; technische Additional-Information-Felder im Finding Reader wurden nicht pauschal umbenannt.
Entscheidungen: vorhandene Core-Tabellen weiterverwenden, keine Migration oder Datenkopie; aktuelle nicht ersetzte Importlaeufe als Standard, historische Laeufe nur explizit; keine Zusammenfuehrung widerspruechlicher Quellwerte und keine Livezustandsbehauptung. Jede Abfrage wird gegen die committed Repository-Revision geprueft. Exakte Quellverweise behalten ihre eindeutigen Ziele auch ueber Abschnittsseiten hinweg. Engines erhalten ausschliesslich den veroeffentlichten Leseport, gebunden an read-only Repository-/DB-Zugriff; bisherige Engines wurden nicht auf andere Inputs umgestellt. Die Data-Flow-Beschreibung des vorhandenen kanonischen Bestands nennt Client Information und den Leseport; es gibt keine neuen Tabellen oder Engine-Schreibbeziehungen.
Mehrwert: Maester 1.2.0 ergaenzt bekannte Cloud-Testdetailwerte aus HTML und Excel/CSV zu Authentifizierung/Conditional Access, Rollen, App-Zustimmung und externem Zugriff/Sharing. Originaltext und enthaltene Tabellen bleiben erhalten. Alle neuen Werte sind als `reported-test-evidence`, `inventoryComplete: false` mit exakter nativer Regel-ID und Quellstatus gekennzeichnet. Ein Teststatus wird niemals als Konfiguration interpretiert. Der native Overall-Score-Vertrag wurde geprueft und bleibt bei der expliziten Nichtverfuegbarkeit eines numerischen Gesamtscores; die gemeinsame Scoringlogik bleibt unveraendert. Historische Verarbeitungsmanifeste bleiben erhalten, fruehere Maester-Importe empfehlen Reimport. Es wurde kein Kunden-Reimport ausgefuehrt.
Verifikation: vollstaendiges `npm test` erfolgreich, **1.853 Tests bestanden, 15 uebersprungen** (darunter die nicht aktivierten echten PostgreSQL-Integrationstests). Neue synthetische Tests pruefen drei Importformate, unveraenderte Finding-Outcomes und Originalwerte, fehlende/unbekannte Nachweise, read-only PostgreSQL-Abfragen, Scopes/Historie, Revisionen, Pagination/Links, HTTP-Validierung und die echte Dashboard-Interaktion in beiden Themes inklusive Apply/Reset/Retry und veralteter Antworten. Architektur-, Engine-Invarianz-, Scoring-, Disclosure-, Local-only- und Designpruefungen bestanden. Veraltete Client-Info-Tests wurden an die bereits verbindliche Directory-Integration vom 2026-10-02 angepasst, mit vollstaendigen Domain-Information-Result-Pruefungen. Synthetische Browserpruefung bestaetigt Dark/Light, vorhandenes Datenfenster und Eigenschaftsfilter. Beide Produktionsbuilds und UI/API-Versionsvertraege bestanden, `git diff --check` ohne Fehler. Der laufende Backend-Healthcheck meldet 2.14.0 und PostgreSQL. Keine Kundendaten wurden fuer Implementierungstests gelesen.
Versionsentscheidung: rueckwaertskompatible neue Abfragefaehigkeit, daher Minor 2.13.5 -> **2.14.0**; Package, Lockfile und Versionsvertraege synchronisiert. Historische Report-/Versionsangaben bleiben unveraendert. Bestehende Warnungen fuer Client-Bundlegroesse und einen React-Key in einem fremden RichTextValue-Test bleiben bestehen. Keine Phase als abgeschlossen markiert; gleichzeitige lokale Arbeiten erhalten.
Offene Grenzen/Risiken: Vollstaendige Cloud-Konfigurationsinventare sind nicht durch Testnachweise ersetzt; unbekannte JSON/XML-/Exportstrukturen benoetigen weitere quellengestuetzte Profilerweiterungen. Die konkrete Abdeckung echter Kundenberichte wurde nicht verifiziert. Neue Extraktionen werden erst bei einem gesonderten regulaeren Reimport sichtbar. Der Port filtert derzeit den vorhandenen scoped Snapshot im Backend, nicht per neuen SQL-Indizes; grosse Scopes koennen entsprechend Arbeitsspeicher benoetigen. Mehrseitige Leser muessen Revisionen vergleichen. Interne Daten bleiben den bestehenden UI-/Export-Disclosure-Regeln unterworfen. Details, genaue ID-/Formatabdeckung und Engine-Nutzung: [CLIENT_INFORMATION.md](CLIENT_INFORMATION.md).
## Client Information - urspruenglicher Nutzerauftrag 2026-10-04 (Planungsstand vor Freigabe)
Der Nutzer benennt den bisher als Zusatzinformationen bezeichneten scannerbezogenen Mandanteninformationsbestand **Client Information**. Der Bestand soll alle bisher gewonnenen Informationen erhalten und gezielt sowohl fuer die heutige ASSESSOR-Anwendung als auch fuer spaetere Engines abrufbar sein. Dazu kommen die besprochenen Cloud-Konfigurationsinformationen, soweit die Originalberichte sie tatsaechlich enthalten. Dies ist eine dokumentierte Anforderung, noch keine implementierte oder verifizierte Core-Aenderung.
Umsetzungsumfang und Abnahmeentwurf:
- Den vorhandenen Bestand aus `EnvironmentObservation` und `ScannerReportSection` als Grundlage verwenden: Originalwerte, komplette Abschnitte/Tabellen, Erfassungszustaende und Datei-/Scan-Provenienz bleiben erhalten. Die einheitliche fachliche Bezeichnung lautet Client Information.
- Einen versionierten, gezielt abfragbaren Lesevertrag bereitstellen: Mandant, Assessment/Timeslot, Scanlauf, Informationsbereich, Eigenschaft und betroffenes Objekt. Client Info und Engines lesen denselben abgegrenzten Datenstand. Stabile fachliche Schluessel erlauben den Abruf ohne UI-Textsuche oder erneutes Parsen durch eine Engine.
- Der Jetzt-Zustand bezeichnet den aktuellen importierten Stand im gewaehlten Scope, mit Scan-/Erfassungszeit und Repository-Revision; er behauptet keinen Livezustand der Kundenumgebung. Historische Laeufe bleiben adressierbar. Widerspruechliche oder unterschiedlich alte Quellen bleiben zugeordnet sichtbar und werden nicht still zusammengefuehrt.
- Zusaetzliche Cloud-Bereiche anhand der tatsaechlichen Quellfelder erschliessen: Anmeldemethoden und Conditional-Access-Konfiguration samt berichteten Ausnahmen; Cloud-Rollen und berichtete aktive/berechtigte Zuweisungen; App-Consent-, Gastzugriffs- und externe Freigabeeinstellungen. Ein vorhandener Test oder Findingtitel beweist weder das Vorliegen der Konfigurationswerte noch ein vollstaendiges Inventar. Fehlende Angaben bleiben ausdruecklich fehlend.
- Engines konsumieren Client Information ausschliesslich ueber deklarierte Read-only-Ports und Read-only-Datenbankzugriff; der Bestand gehoert zum Core. Bestehende Privacy-/Disclosure-Regeln gelten auch fuer den neuen Lesevertrag. Keine engineeigene Kopie oder Schreibmoeglichkeit in Core-Daten.
- Namensaenderungen nach fachlicher Bedeutung vornehmen: Die bisherige Finding-Reader-Rubrik `Additional Information` enthaelt auch technische Einzelbefundfelder und ist nicht automatisch identisch mit dem Mandanteninformationsbestand. Keine pauschale Text-Ersetzung als Ersatz fuer den gemeinsamen Informationsvertrag.
- Synthetisch pruefen: gezielter Abruf bestehender und neuer Informationen, Scope-/Mandantentrennung, historische Datenstaende, fehlende/widerspruechliche Werte, Herkunftstreue, identische UI-/Engine-Grundmenge sowie unveraenderte Findings/Verdicts/Scores und Engine-Invarianz. Betroffene UI-Interaktionen dauerhaft absichern.
Bestandspruefung: `getEnvironmentSnapshot` existiert bereits im Repository und in der API; Context- und Report-Read-Ports nutzen ihn. Der jetzige Snapshot-Query grenzt nach Mandant, Assessment und Scanlaeufen ein; fachlich gezielte Abfragen sind darauf aufbauend zu gestalten. Die konkrete Cloud-Feldabdeckung der Kundenberichte ist noch nicht verifiziert. Bestehende Importe erhalten neue extrahierte Werte nur durch einen gesonderten regulaeren Reimport.
Core-Umsetzung wartet auf die Freigabe der betroffenen Bereiche Ingest, Normalization, Store, Presentation, Platform und Core Tests. Eine Schemaaenderung oder ein Reimport wurde weder vorgenommen noch durch diese Dokumentation freigegeben; falls erforderlich, gelten die bestehenden Backup-/Migrationsregeln. Dokumentationsaenderung ohne Produktversionsanhebung, ohne Laufzeitaenderung und ohne Phasenabschluss.
2026-10-02 - Phase 7 / approved Management Export assignment wording (Report renderer 3.1.1, verified): User explicitly selects Nach Katalog, Nach Befundmerkmalen and Thematisch eingeordnet. New Management Reports use these labels consistently in the reading key, category assignment tables and count summaries. Descriptions use plain technical/content-clue wording; thematic assignments explicitly remain pending professional confirmation. Report-authored Geschätzt/geschätzt and deterministic/anchor wording are removed from Management HTML. Internal catalogued/recognized/estimated values, counts, technical export, source data and historical archives are unchanged. The permanent Report Engine template records this user decision.
18 focused management, report-run/narrative, boundary and invariance tests passed, including a new synthetic export test with nonzero counts in all three tiers, exact labels throughout, preserved qualification and unchanged input. Synthetic management preview regenerated through the production renderer; server production build and design/local-only checks passed. Management renderer patch 3.1.0 -> 3.1.1 records the editorial change in new archive metadata. Product version reassessed and retained at 2.13.5: this is a terminology-only Report Engine edit with no Core/product capability, schema, data or layout change. No Core source/test/version files were edited. Existing local backend restarted to activate the renderer, retaining canonical PostgreSQL; frontend API health is ok. No phase completion or new dataflow relationship, no full-suite rerun.
2026-10-02 - Phase 7 / Activity reload control (2.13.5 patch, verified): Current Presentation/Core Tests/Platform release adds the requested neutral circular-arrow button immediately after History when loading ends incomplete or cancelled and no task remains active. Accessible name/title Reload application; mouse or native Enter reloads the current application page through window.location.reload. The same action remains available beside a manually minimized incomplete Activity indicator. It does not mark failures as recovered, clear history or alter readiness; running/queued work and recovered Ready suppress it. Existing green Ready dot, manual minimization, five-second automatic minimization and historical failure behavior remain. Shared button geometry, token-sized SVG and both themes retained.
Permanent synthetic React interactions in Dark/Light verify presence after failure, click callback from expanded/minimized states, unchanged activity records, absence while running and after same-operation recovery despite historical failure. 24 Activity/UI/API version tests passed; both production builds, design/local-only checks and diff check passed. Browser synthetic preview verified native Enter and mouse reload navigation, both themes and visible compact geometry; screenshot saved. Patch 2.13.4 -> 2.13.5 with package/lock/version contracts synchronized. No persistence, API/dataflow or engine relationship change, backend restart or full-suite rerun; existing bundle-size warning remains.
2026-10-02 - Phase 7 / approved Management Report template retained: user explicitly approved the implemented report and requested a permanent template inside Report Engine. Added src/reporting/MANAGEMENT_REPORT_TEMPLATE.md with binding visual/content/data contracts and linked it from REPORT_STRATEGY.md. Existing renderer remains the executable template. Documentation-only; no product version change or runtime changes. Dataflow inspection found Context -> Report still marked Planned in EngineDataFlow.tsx despite the implemented publication bridge; diagram update remains a separate Presentation/Core Tests/Platform released request. No phase completion claimed.
2026-10-02 - Phase 7 / Client Info layout rollback (2.13.4 patch, verified): Explicit user correction "Setz alles wieder zurueck! Wieder wie vorher!" followed by current Presentation/Core Tests/Platform release supersedes the compact-workspace selection documented below. Only the last Client Info layout batch is reverted: Overall Score and Security Domains again sit side by side with the established connectors, Data and Domain Information use their previous arrangement, the compact Context heatmap is full-width underneath, and square environment tiles return to their horizontally scrollable row. Original coverage/ring dimensions, responsive breakpoints and environment-tile first-read disclosure are restored. Accepted Risks and all earlier functionality, including six clickable Data facts, complete Domain Information Results, heatmap totals/cluster drilldowns, engine preferences and report actions, remain intact. No unrelated concurrent changes were reverted; frontend/backend continue running against the canonical datastore.
The permanent synthetic full-Dashboard regression now checks this restored layout in both themes, preserved source values/Core counts, complete native Domain Information Results and the exact Context-to-Work click. 63 focused layout, popup, disclosure, risk, report, engine-boundary/invariance and UI/API version tests passed; both production builds and design/local-only checks passed. Synthetic browser inspection confirms the restored geometry, both themes and 14px labels/16px values; screenshot saved. Product patch 2.13.3 -> 2.13.4 records the verified rollback without rewriting historical release/report versions. Existing client bundle-size warning remains. No schema/dataflow change, backend restart, full-suite rerun or phase completion claim.
2026-10-02 - Phase 7 / management report reference layout (2.13.2 patch, verified): User-provided Huntsman page explicitly becomes the exported Management Report's layout/style reference: white report sheets, ASSESSOR logo in header/footer, orange headings, dark table headers, alternating light rows, two-column explanatory/evidence blocks and factual bar charts. This export-only user decision supersedes earlier app-style guidance for this report's palette/layout; Core application views and tokens stay unchanged. Existing ASSESSOR logo and Inter are embedded offline. Overview and heatmap precede one sheet per populated Context category; each shows catalog description, separate scanner/GPO counts, category Base Severity, confidence and source attribution, followed by scope/method appendix. Footers number logical report sheets, not physical PDF pages. No invented time series, score, impacted endpoint count, ownership or business consequence. Coverage chart uses published assessed counts; category bars use published scanner/GPO counts. 51 distinct focused report/invariance/export/UI/API version tests passed (module-list sort corrected and rechecked), both production builds and design/local-only checks passed. Browser synthetic preview has loaded logo, two-column/table/chart layout and no horizontal overflow at the normal viewport. No PDF pagination or real customer regeneration acceptance claimed. Provided downloaded HTML was inspected read-only and remains unchanged. Product/lock/version contracts synchronized; no backend restart, no schema or engine relationship change; phase remains in progress.
2026-10-02 - Phase 7 / Client Info, selected compact workspace (2.13.3 patch, implemented and verified): User explicitly selected the attached third proposal, Kompakter Arbeitsbereich, then released Presentation, Core Tests and Platform. The existing Client Info now groups Overall Score and coverage, all Security Domains and the Context heatmap in the left analysis column; Data with its six clickable facts, Domain Information and all five Environment groups occupy the right column. Accepted Risks remains the existing full-width list/detail register below. This chosen layout supersedes the earlier six-square-tile environment row and side-by-side posture arrangement: environment groups are stacked flat reading surfaces, with existing first-read fields and complete Results retained. Decorative ring-to-domain connectors are hidden in the stacked arrangement; scores, source-balanced computation, population semantics, source selection, report actions, engine choices, native evidence, risk records and Work drilldowns are unchanged. No API request, parsing, persistence or engine relationship was added. The heatmap retains its compact 186px tiles with up to four columns, all 22 categories and unique Finding count. Narrow containers stack the columns; the shared root inset remains 20px and fields retain 14px labels/16px values in both themes.
Verification: permanent synthetic full-Dashboard interaction tests in Dark/Light check left/right placement, all eight domains plus applicable Unclassified, six Data facts, five environment groups, full-width risk register, all 22 heatmap categories, unchanged Core values when enabling Context, opening all eleven Domain Information fields and original evidence, closing Results and selecting the exact Work cluster, with unchanged input data. Existing six-fact popup, progressive disclosure, posture, risk, report-control, Context boundary and Report invariance checks remain. 63 focused tests passed, including UI/API product-version contracts; both production builds, local-only/design checks and diff check passed. Browser inspection used a static rendering of the actual synthetic Dashboard output to verify both themes, responsive stacking, typography and no horizontal overflow; event behavior was verified in the actual React interaction tests, not the static preview. Screenshot saved. Patch 2.13.2 -> 2.13.3, package/lock and both version contracts synchronized. Existing client bundle-size warning remains; no full-suite rerun, real-client data change, backend restart or phase completion claim. Concurrent changes preserved.
2026-10-02 - Phase 13B / Dataflow presentation clarification (2.13.1 patch, verified): Result DB materialization tile now states Data protection & disclosure and explicitly describes removal or masking of sensitive identity data, validation before storage and the shared controlled result set for Result DB UI and exports. This documents existing disclosure behavior only; internal-analysis remains distinct. No processing, schema, engine relationship, arrow style or selection change. Current Presentation/Core Tests/Platform consent; 17 focused Dataflow/UI/API version tests, design check and both production builds passed. Package/lock and version contracts synchronized. Existing client bundle size warning remains; no backend restart, no new phase completion, no screenshot acceptance claimed.
## Context-based Management Report (Phase 7 / published Phase 13E results)
2026-10-02 - Version 2.13.0, implemented and focused verification complete: current Presentation/Core Tests/Platform release connects the management export to Context Engine's published, frozen result contract. Report Engine does not import Context internals; the already pinned backend extension composes published Context results through the canonical read-only port. The same project, assessment IDs, scan-run IDs and repository revision are checked before rendering/archive; unstable reads retry, mismatched scope or absent producer blocks management generation. Core remains unchanged with engines off. New management generation is blocked in Client Info while Context is off and late UI output is suppressed; technical-only generation still works without computing Context, with no fallback management file. Historical archive renderers and projections are preserved.
Delivered: Management Summary, all 22 category tiles sorted by actual Context color, unique Finding total, populated problem fields with scanner/GPO counts and catalogued/recognized/estimated confidence, generic review guidance referencing original recommendations in the Technical Report, documented risk acceptance and selected work states, conditional existing delta evidence, environment/source/time coverage and Domain appendix. No invented business loss, probability, ownership, deadlines, effort, threat scenarios or new priority. General category descriptions are labelled as catalog explanation, not a proven shared attack path. Category Base Severity and technical verdicts stay distinct; neutral is not secure, overlapping clusters are not summed, GPO backups are not effective host observations. Missing comparison evidence is stated explicitly. Result IDs stay out of Management HTML and are preserved with matching Context counts in Report B and the confidential frozen archive JSON. Standalone HTML uses embedded existing Inter, no script or external resources. The export reads Context only on demand, reusing a matching cached result; no additional customer-load work.
Verified: 67 focused Report/Context/publication, actual Client Info export interaction, HTTP generation, archive/replay, on/off invariance, architecture and UI/API version tests passed; both final production builds, design/local-only checks and diff check passed. Synthetic browser preview uses the production Context publisher and renderer with 22 categories, no customer data. Existing client bundle-size warning remains. Capability release 2.13.0 supersedes concurrently established 2.12.6; package/lock and UI/API version contracts synchronized. Report Engine 0.3.0, projection 1.3.0 and management renderer 3.0.0 version the new artifact. No persistence migration, canonical datastore change, backend restart or phase completion claim. Current running backend must load the new build before the live export changes. New implemented data-flow relationship: published Context results -> Report Engine; remind the user to request the Engine diagram update separately. A real customer export and rescan-backed delta were not accessed in this side conversation.
## Context-Heatmap in Client Info (Phase 13E / 7)
2026-10-02 - Phase 7 / Data-Kennzahlen als Results-Einstieg (2.12.6, umgesetzt und fokussiert verifiziert): Aktuelle Presentation/Core-Tests/Platform-Freigabe macht AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers und GPOs zu nativen klickbaren Kennzahl-Kacheln. Die bestehende EnvironmentResultsButton-/FloatingDataWindow-Interaktion zeigt den unveraenderten Uebersichtswert, passende retained Originalbeobachtungen mit Quelle/Scan und die jeweiligen originalen Reportabschnitte samt bestehenden Such-/Detail-/Paging-Funktionen. Eigene Evidenzgruppen begrenzen User-/Computer-/DC-/Domain-/Entra-/GPO-Inhalte. GPO Results zeigt die bereits geladenen verarbeiteten Backup-Dateien, Anzahl, Status und Zeit; der bestehende projektweite Zaehler und seine Datenpopulation bleiben erhalten und werden als All processed client backups benannt. Bestehende GPO-GUID- bzw. Maester-Praesenz-Fallbacks werden als solche erklaert, ohne Detailobjekte zu erfinden. Unbekannte Werte bleiben ein Gedankenstrich; fehlende Quellabschnitte bleiben explizit fehlend. Kachelgeometrie, beide Theme-Tokens und 14px-Daten/16px-Werte bleiben erhalten, Hover und blauer Tastaturfokus zeigen die neue Interaktion. Escape/Close stellt Fokus auf die ausloesende Kennzahl zurueck; Oeffnen aendert weder Work-Filter noch Daten und erzeugt keine neuen Requests. Permanente synthetische Tests klicken alle sechs echten Dashboard-Kennzahlen in beiden Themes, pruefen exakte Werte/passende Evidenz, getrennte User-/Computer-Inhalte, Backup-Herkunft, Escape/Fokus, Dateninvarianz und Requestzahl; separater Test prueft unbekannten Wert ohne erfundene Null/Zeilen. 53 unterschiedliche fokussierte Kennzahl-/Domain-/Evidenz-/Layout-/UI-API-Versionspruefungen bestanden (52 im ersten Lauf, abschliessend drei Kennzahltests einschliesslich neuer Missing-data-Abnahme); Design-/Local-only-Pruefung, diff check und beide Produktionsbuilds bestanden. Synthetische Browserabnahme mit Enter, Escape, Maus und Dark/Light; Screenshot gespeichert. Patch 2.12.5 -> 2.12.6, Package/Lockfile und Versionsvertraege synchron. Kein Vollsuite-Neulauf, Backend-Neustart, Persistence-/API-Vertrags- oder Engine-/Dataflow-Beziehungswechsel; bestehende Bundle-Warnung und concurrent changes bleiben, keine Phasenabschlussbehauptung.
2026-10-02 - Phase 13E / 7, eindeutige Heatmap-Gesamtzahl (2.12.5, umgesetzt und fokussiert verifiziert): Auf aktuelle Presentation/Core-Tests/Platform-Freigabe steht Findings: N unmittelbar links neben der Severity-Legende. Die Anzahl umfasst die eindeutigen Scanner-Abweichungen und GPO-Settings, die in der Heatmap des ausgewaehlten Timeslots vertreten sind. Die vorhandene Evidenzprojektion wird fuer Kacheln und Gesamtzahl gemeinsam verwendet; mehrere Bedingungen, Evidenzen oder Clusterzuordnungen desselben Findings erhoehen die Gesamtzahl nicht. Scanner und GPO behalten getrennte Identitaeten, GPOs ihren Scope; bestandene Ergebnisse bleiben ausgeschlossen und der bestehende Recognition-Reassignment-Ausschluss bleibt erhalten. Neue Context-Daten aktualisieren den memoisierten Zaehler, leere Daten zeigen 0; Lade-/Fehlerzustaende zeigen keine erfundene Zahl. Keine Quellen-, Verdict-, Katalog-, Persistenz- oder Engine-/Dataflow-Beziehungsaenderung. Permanente synthetische Komponenteninteraktion in beiden Themes prueft Position vor der Legende, 29 GPOs plus zwei Scanner, ueberlappende Cluster/Evidenz, Pass-Ausschluss, unveraenderte Daten, Cluster-Klick und Wechsel zu leerem Scope; Dashboard-Klick prueft zusaetzlich die Gesamtzahl. 30 fokussierte Heatmap-, UI/API-Versions- und Core-Grenztests, Design-/Local-only-Pruefung und beide Produktionsbuilds bestanden. Synthetische Browserabnahme in Dark/Light und schmalem Container ohne Ueberlauf; Screenshot gespeichert. Patch 2.12.4 -> 2.12.5 fuer rueckwaertskompatible UI-Ergaenzung, Package/Lockfile und Versionsvertraege synchron. Kein Vollsuite-Neulauf oder Backend-Neustart; bestehende Bundle-Groessenwarnung bleibt, keine Phasenabschlussbehauptung und concurrent changes erhalten.
2026-10-02 - Phase 7 / Directory in Domain Information integriert (2.12.4, umgesetzt und fokussiert verifiziert): Nach aktueller Presentation/Core-Tests/Platform-Freigabe entfaellt die separate Directory-Kachel. NetBIOS name, Schema version, Domain created und Entra ID erscheinen zusammen mit den bestehenden Domain-Angaben in Domain Information. Der gemeinsame Results-Button verwendet die bestehende FloatingDataWindow-Interaktion und zeigt alle elf Domain-Information-Felder, einschliesslich der oben bereits dargestellten Domains-/DC-Zaehler und unbekannter Angaben, sowie die bisherigen Directory-Quelltabellen und Beobachtungen. Nur die erste Leselinie vermeidet weiterhin doppelte Zaehler; Results wird nicht durch diese Uebersichtsfilter gekuerzt. Keine neuen Quellen oder fachlichen Ableitungen. Permanenter synthetischer Dashboard-Klicktest in beiden Themes prueft die vier integrierten Werte, das Fehlen der Directory-Kachel, alle elf Result-Felder, Originalevidenz und unveraenderte Work-Filter. 50 fokussierte Dashboard-/Result-/UI-API-Versionspruefungen, Design-/Local-only-Pruefung, diff check und beide Produktionsbuilds bestanden. Patchversion fuer die rueckwaertskompatible UI-Zusammenfuehrung; Package/Lockfile und Versionsvertraege synchron. Abnahmegrenze: synthetische DOM-Interaktionen und bestehende Theme-Tokens, keine neue Browser-Screenshot-Abnahme. Bestehende Bundle-Groessenwarnung bleibt; kein Backend-Neustart, keine Persistenz- oder Engine-/Dataflow-Aenderung, keine Phasenabschlussbehauptung.
Build-Abnahme zur nachfolgenden Korrektur 2.12.3: Client- und Serverproduktionsbuild erfolgreich; bestehende Client-Bundle-Groessenwarnung bleibt. `git diff --check` ohne Fehler.
2026-10-02 - Context-Zaehlung und Smartcard-Zuordnung korrigiert (Phase 13E, Patch 2.12.3): Die Heatmap ignoriert den historischen Fallback-Condition-Eintrag eines GPO-Findings nach `assigned-by-recognition` und zaehlt es nur in seinen zugeordneten Clustern. Zuvor zeigte Other solche bereits zugeordneten GPO-Abweichungen weiterhin, waehrend Work sie korrekt ausschloss. Der permanente synthetische Dashboard-Interaktionstest reproduziert 29 neu zugeordnete GPO-Settings plus zwei verbleibende Scanner-Findings und prueft nach Kachel-Klick die Uebereinstimmung von Kachelzahl und Work-Facet-Mitgliedschaft. Katalog 1.9.1 ergaenzt Purple Knight SI000147 anhand des vom Nutzer vorgelegten Berichtstexts: deaktivierte Smartcard-only-Kennwortrotation gehoert zu Lateral Movement Exposure; eine eigene Bedingung und vorsichtige Aussage bewahren die konkrete Bedeutung, ohne eine ATT&CK-Technik oder einen kompromittierten Zustand zu behaupten. Die unspezifische Maester-Regel MT.1024 ohne konkrete Empfehlung bleibt Other; keine Zuordnung aus der generischen Rule-ID. Regressionen pruefen fail/warning, Pass-/Not-run-Ausschluss, unveraenderte Eingabedaten und exakte Regelzuordnung. 100 unterschiedliche fokussierte Context-, Reader-, Facet- und Produktversionspruefungen bestanden, einschliesslich erneuter Reader-Pruefung nach Anpassung des dynamischen Katalogversionsvertrags. Design-/Local-only-Pruefung bestanden. Produkt-Patchversion nach aktueller expliziter Platform/Core-Tests-Freigabe synchronisiert; keine Core-Finding-, Verdict-, Severity-, Datenbank- oder API-Vertragsaenderung. Kein Backend-Neustart oder Kundendatenzugriff; Phase bleibt in Arbeit. Produktionsbuild-Abnahme wird nach Abschluss unten ergaenzt.
2026-10-02 - Version 2.12.2, kompakte Heatmap und direkter Finding-Klickpfad umgesetzt und fokussiert verifiziert: Die ausdrueckliche Nutzerkorrektur ersetzt die nachfolgend historisch dokumentierte Detailauswahl. Jede Kachel zeigt nur Base Severity, Kategorie und die Gesamtzahl zugeordneter Scanner-/GPO-Findings. Die vorhandene Deduplizierung bleibt bestehen; Bedingungen sind keine Findings. Kacheln verwenden die gemeinsame kompakte Breite von 186px und werden stabil nach ihrer dargestellten Farbe sortiert: Critical, High, Medium, neutral. Klick, Enter und Leertaste oeffnen unmittelbar Work mit dem exakten Context-Cluster-Filter und Abweichungsstatus, ohne Heatmap-Detailansicht. Alte Such-/Source-/Arbeitsfilter werden zurueckgesetzt; aus einem einzelnen Quellblatt wird auf das befuellte Projektblatt gewechselt, damit die gesamte Clusterliste ueber alle Quellen erreichbar ist. Timeslot und Client bleiben erhalten. Keine Aenderung an Quellen, Katalog-Grundschwere, Verdicts, Core-Scores oder Persistenz; keine neue Engine-/Dataflow-Beziehung. Abnahme: 60 fokussierte Tests ueber Context, Architektur, Dashboard, Quellnavigation und UI/API-Versionsvertraege bestanden; Design- und Local-only-Pruefung sowie beide Produktionsbuilds bestanden. Synthetische Browserpruefung in beiden Themes und bei 358px Containerbreite ohne Ueberlauf, mit Maus und Tastatur, bestanden. Permanente Interaktionsregression prueft den echten Dashboard-Klickpfad einschliesslich Quellblattwechsel. Patchversion fuer die rueckwaertskompatible Darstellungs-/Navigationskorrektur, Package/Lockfile und Versionsvertraege synchron. Bestehende Bundle-Groessenwarnung bleibt; kein Backend-Neustart und keine neue Abnahme realer Kundendaten. Phase 13E bleibt in Arbeit.
2026-10-02 - Version 2.12.0, umgesetzt und fokussiert verifiziert: Auf Nutzerauftrag ist die recherchierte Tabelle mit allen 22 bestehenden Context-Clustern als eigener versionierter Quellenkatalog `src/assessment-context/context-cluster-severity.ts` (1.0.0, Recherche-/Pruefdatum 2026-10-02) hinterlegt. Einstufung: vier Critical, 13 High, vier Medium, ein Undetermined. Jede fachlich eingestufte Kategorie besitzt Begruendung, Einschraenkung und lokal gespeicherte Quellenangaben; die Einstufung ist eine kuratierte ASSESSOR-Grundschwere und kein von den Quellen vergebener Cluster-Score. `Other Deviations` bleibt unbestimmt. Die bisherigen Security Domains und ihre Zuordnung bleiben unveraendert.
Entscheidung und begrenzte Erweiterung des bisherigen Context-Darstellungsvertrags: Die neue Heatmap darf die bestaetigte statische Kategorie-Grundschwere mit den bestehenden Severity-Farbtokens darstellen, wenn das vorhandene Context-Ergebnis Belege enthaelt. Sie erzeugt keine Finding-Severity, kein CVSS, keine normalisierte Prioritaet, kein Pass/Fail und keine clientbezogene Aussage zur Ausnutzbarkeit. Ohne Belege sowie bei unbestimmter Grundschwere bleibt eine Zelle neutral; neutral bedeutet nicht sicher. Bedingungen, Clusterzustand und Zuordnungssicherheit bleiben getrennt. Die Bedingungen und Cluster-Schwellen werden unveraendert aus Context uebernommen. Scanner-Abweichungen, GPO-Baseline-Abweichungen, deklarierte Techniken sowie erkannte und geschaetzte Zuordnungen werden aus dem vorhandenen Ergebnis gelesen und pro Cluster nach Finding-Occurrence bzw. GPO-Identitaet/Scope dedupliziert; es gibt keine neuen Quellen, Neuinterpretation von Verdicts oder Requests pro Zelle. Reine GPO-Konfigurationsbelege bleiben als Bedingungen erkennbar und werden nicht als beobachtete Host-Findings ausgegeben. Cluster duerfen ueberlappen; ihre Zahlen werden nicht zu einer vermeintlich eindeutigen Gesamtsumme addiert.
Client Info zeigt die Heatmap fuer den ausgewaehlten Timeslot ueber alle Quellen, ausdruecklich unabhaengig vom Source-Auswahlfeld. Sie nutzt denselben vorbereiteten Context-Abruf wie die bestehenden Leser. Ein Klick bzw. Enter/Leertaste markiert die Zelle gelb und oeffnet deren Grundschwere, Bedingungen, Zuordnungssicherheit, Begruendung und Quellen. Der Fokus wandert zu den Details und beim Schliessen zurueck zur Zelle. Bei Client-/Scope-/Revisionswechsel verfaellt die Auswahl. Context aus entfernt die Heatmap vollstaendig und erzeugt keine neuen Anfragen; spaete Antworten bleiben ausgeblendet. Ladefehler werden nicht als leere oder sichere Bereiche dargestellt. Der neue Core-Erweiterungspunkt in `DashboardPanel.tsx` ist nach aktueller `Core-Freigabe: Presentation, Core Tests, Platform` in der Architekturpruefung eingetragen; fachliche Core-Berechnungen bleiben frei von Context-Abhaengigkeiten.
Abnahme: 116 fokussierte Context-, Architektur-, Client-Info-, Navigations- und UI/API-Versionsvertragstests bestanden; anschliessender Fokus-/Tastaturtest 4/4 nach letzter Scrollkorrektur bestanden. Der synthetische Test mit dem echten Dashboard vergleicht die vollstaendige Core-Darstellung und Dataset-Werte bei aus-/eingeschalteter Engine. Weitere Tests pruefen Vollstaendigkeit aller 22 Eintraege, fehlende native Scanner-Severity, deduplizierte Scanner-/GPO-Zahlen, Passed-Ausschluss, unbekannte Bereiche, unveraenderte Context-Ergebnisse und spaete Antworten. Synthetische Browser-Sichtpruefung in Hell/Dunkel und bei 358px Containerbreite ohne horizontalen Ueberlauf, einschliesslich Maus, Enter, Leertaste und Fokusrueckkehr, bestanden. Beide Produktionsbuilds, Design- und Local-only-Pruefung bestanden; bestehende Bundle-Groessenwarnung bleibt. Minor-Version 2.12.0 fuer die neue rueckwaertskompatible Faehigkeit; Package, Lockfile und Versionsvertraege synchron. Kein Datenbankeingriff, keine Migration, kein Backend-Neustart und keine Phasenabschlussbehauptung. Abnahmegrenze: synthetische UI-/Datenpruefung, keine neue Pruefung eines realen Kundenbestands. Neue dargestellte Beziehung: Context Engine -> Client-Info-Heatmap; Engine-Diagramm-Aktualisierung bleibt ein gesonderter Nutzerauftrag gemaess Erinnerung unten.
## Verbindlicher Diagramm-Pfeilstandard
2026-10-02 - Phase 7 / Ready indicator and explicit minimization (2.12.1 patch, implemented and verified): current Presentation/Core Tests/Platform release adds the requested green system-status dot beside Ready in both expanded Activity and the single compact button. Ready text remains accessible; the decorative dot uses the existing theme-aware state-online token. A native minimize button with an × sits immediately right of History when Ready; clicking or pressing Enter collapses the panel and its disclosure without removing history or changing readiness. The existing five-second automatic minimization and new-work restoration remain unchanged. Permanent both-theme Activity interaction acceptance verifies the actual minimize/reopen path, retained failed history, dot presence only while Ready, action placement and absence while waiting/not ready. Verified: 22 Activity and UI/API version tests passed, security/design checks and both production builds passed. Synthetic production-component browser verification confirms both theme colors, automatic compact state, mouse/Enter minimization and history reopening; screenshot retained. Version decision: backward-compatible presentation refinement, patch from the concurrently established 2.12.0 to 2.12.1, package/lock and both version contracts synchronized. No full-suite rerun for this focused correction; existing client bundle warning remains. Concurrent changes preserved, no phase completion or engine/data-flow change.
2026-10-02 - Phase 7 / confirmed client identity edits (2.11.10 patch, implemented and verified): explicit user decision requires a mandatory warning before saving changes to the existing Client Information Name or Consultant. Current Presentation/Core Tests/Platform release implements that guard in the shared native form-submit path, including implicit Enter submission. The dialog lists only changed fields with previous and proposed persisted values. Cancel, Escape, close and backdrop dismissal write nothing and retain the draft for review; Confirm changes saves the reviewed form snapshot through the existing API and read-back verification. Re-submitting after cancellation requires confirmation again. Unchanged identity fields, Remarks-only edits and new-client creation retain their existing flow. Initial focus is Cancel, keyboard focus cycles inside the dialog and closure restores the prior focus; Enter on Cancel never confirms. A synchronous save guard prevents duplicate API writes. Existing project ID, creation time and unrelated attributes are retained; no repository/schema/API persistence contract change. Shared dialog, theme surfaces, control geometry and 14px labels/16px values are used; no new palette. Permanent actual DataHub interaction acceptance covers Name, Consultant and both changes, old/new values, no writes before confirmation, Escape/Cancel, native form resubmission, one confirmed save, read-back and preservation of unrelated data; Remarks-only edits remain direct. Verified: 19 Client Info/reference interaction/layout tests plus 15 version/API/Core-boundary tests passed; local-only security/design checks and both final production builds passed. Synthetic shared-dialog browser verification covers dark/light, Cancel and explicit confirmation by Enter, focus wrapping and readable old/new values. Version decision: backward-compatible presentation safety correction, patch 2.11.10. No full-suite rerun for this focused change, no phase completion claimed; existing bundle warning and concurrent changes preserved. No engine/data-flow relationship change or diagram update required.
2026-10-02 - Phase 7 / minimal Ready button (2.11.9 patch, implemented and verified): current Presentation/Core Tests/Platform consent implements the user's correction that the minimized Activity panel still obstructed work. Collapsed Activity now renders one neutral shared-geometry Ready button, with no panel surface, border, padding or second History control. Its transparent positioning wrapper fits the button instead of reserving the 560px panel width. Clicking or pressing Enter opens the existing panel with History already expanded. New work still opens the full current status and the existing five-second Ready timer is unchanged. Manual collapse of a non-ready state retains its actual status text rather than asserting Ready. Readiness, request identity, historical outcomes and data quality contracts remain unchanged. Permanent both-theme interaction acceptance now verifies a single Ready button, absence of the panel, direct history opening and automatic panel restoration from the tiny state when new work starts. Updated earlier interaction tests select the newly mounted compact control after collapse instead of clicking a detached panel button. Verified: 22 activity/UI/API version tests passed; local-only security and design checks and both production builds passed. Synthetic current-component browser checks confirm dark/light, mouse/Enter history opening and an approximately 66x28px compact control with a transparent, unpadded, borderless wrapper. Version decision: backward-compatible presentation refinement, patch 2.11.9. No full-suite rerun for this focused correction, no phase completion claimed; existing bundle warning and concurrent changes preserved. No engine/data-flow change or diagram update required.
2026-10-02 - Phase 7 / final Ready and timed minimization (2.11.8 patch, implemented and verified): current Presentation/Core Tests/Platform release implements the user's final-state correction. Activity shows Ready only when no operation is active/queued and the latest attempt for every tracked operation has completed. A successful retry replaces that operation's earlier failure for readiness while keeping the failed invocation in History. API operation identity includes backend, method and exact request path/scope; a success for a different client cannot resolve another client's request failure. Display labels can change without changing operation identity; identifiers/URLs remain internal and are never rendered. Latest operation outcomes are retained separately from pruned/cleared history, so clearing or dismissing rows cannot manufacture readiness. This is conservative: an outstanding unsuccessful operation still prevents Ready until the corresponding operation succeeds. Earlier failures/cancellations now appear only inside the optional History, without a Needs attention decision queue. This explicitly supersedes the 2.11.6 failure-first presentation and immediate all-success collapse. Ready stays expanded for five seconds, then automatically minimizes to the compact Activity/History bar. New work opens the panel, cancels an earlier timer and starts a fresh five-second interval after successful completion; manually reopening History after minimization stays open until another load cycle. No request is retried or changed by this UI. Native buttons, existing neutral styles and readable Ready content retained. Verified: twelve activity acceptance tests include both themes, recovered failures, exact 4,999/5,000ms boundary, queued/new work at an old deadline, reopening History, exact request-scope matching, clearing/dismissing/pruning invariance and concurrent API behavior. Full suite 1,807 passed, 15 skipped; local-only security/design checks and shared UI/API version contracts passed; both production builds passed. Synthetic production-component browser checks confirm Ready, automatic minimization and History reopening in dark/light. Version decision: backward-compatible status correction, patch 2.11.8. No source, assessment, engine execution, persistence or Data Flow relationship change; no diagram update required. Existing bundle warning remains, no phase completion claimed, concurrent work preserved.
2026-10-02 - Phase 7 / reachable Context Cluster options (2.11.7 patch, implemented and verified): current Presentation/Core Tests/Platform release applies the user's request to the shared Work filter projection. Context Cluster options with zero hits under the existing self-excluding facet counts are no longer offered. Search, status and other active restrictions still determine those counts; selecting a cluster does not hide reachable alternatives from that same facet. All remains available, including an empty scope. A currently selected cluster that becomes empty remains visible as a disabled current option so the UI does not silently drop a restriction; All explicitly clears it. This supersedes the earlier Work-control behavior of offering every catalog cluster at zero hits. The complete engine vocabulary and results, all other declared facets, filter counts and engine activation remain unchanged. Permanent actual SearchFilterBar acceptance covers selecting a cluster, narrowing search to another cluster, disabled zero-hit selection retention, choosing All and a completely empty result scope. Existing badge selection and both-theme checks remain. Verified: 49 filter/Context/GPO tests plus 20 UI/API version and architecture/invariance tests passed; local-only security and design checks and both production builds passed. Version decision: backward-compatible presentation correction, patch 2.11.7. No full-suite rerun for this small focused correction, no phase completion claimed; existing bundle warning and concurrent changes preserved. No data-flow or engine relationship change; no diagram update required.
2026-10-02 - Phase 7 / activity overview (2.11.6 patch, implemented and verified): current Presentation/Core Tests/Platform consent implements the approved compact activity design. Needs attention appears before running/waiting work, cancellations stay distinct, and the summary includes failures even while other operations run. The operation label is primary and the engine secondary. Successful history starts collapsed; an explicitly opened panel/history stays open through updates. Exact engine/label repeats form expandable groups retaining every recorded invocation. The fixed panel header sits above a scrolling body using the shared 20px inset, 14px informational text and existing controls/theme tokens. All-success state defaults to a compact header with History count. Durations below one second show <1s; active durations update from real timestamps, with no inferred percentage or ETA. Clear completed removes only successes. Failures and cancellations survive success-history pruning and can be acknowledged individually with Dismiss, which never removes active tasks. The existing 40-success history bound remains; acknowledged task records are UI telemetry, not assessment data. Legacy clearFinishedActivities remains for existing non-UI cleanup callers. No invented error detail or retry action: current telemetry contains a state, engine, operation label and timestamps only. No source, result, engine execution, database or Data Flow relationship changes; no diagram update required. Verification: eight activity acceptance tests include real concurrent API operations, exact grouping, opened-history invariance, failure/cancellation retention through pruning and success clearing, individual dismissal and active-task protection. Full suite 1,802 passed, 15 skipped; local-only security and design checks passed; shared UI/API version contracts and both production builds passed. Synthetic current-component browser checks cover light/dark, compact all-success state, keyboard history opening, group expansion and failure/active preservation after Clear completed. Version decision: backward-compatible UI/status refinement, patch 2.11.6. Existing client bundle-size warning remains; no phase completion claimed and concurrent work preserved.
2026-10-02 - Phase 7 / separate no-hit recovery actions (2.11.5 patch, implemented and verified): current Presentation/Core Tests/Platform release implements the user's correction in Work, Result DB and Pathfinder. The shared companion now has exactly two actions. Reset filters delegates to each view's existing normal reset, including Work/Pathfinder's existing My Workspace behavior and Result DB's default Deviation filter. Search all Findings clears result restrictions and selects All Findings within the current client, timeslot and active sheet; Result DB clears filter values while retaining the exact project/assessment/scan scope. The user's two-action correction explicitly supersedes the earlier separate source-preserving no-hit recovery button: Search all Findings also clears the source facet. Pathfinder retains its binding deviation-only graph scope; its Search all Findings action opens Work with the All filter through the existing navigation callback. No source, verdict, disclosure, persistence or engine behavior changes; no Data Flow diagram update required. Permanent synthetic interaction acceptance compares the no-hit Reset action directly with normal reset and verifies both actions in all three real views, Pathfinder-to-Work navigation, unchanged client/timeslot/sheet, unchanged source data and scoped Result DB queries. Verification: full suite 1,799 passed, 15 skipped; local-only security and design checks passed; UI/API product-version contracts and both production builds passed. The earlier import timeout did not recur in this run. Version decision: backward-compatible correction, patch 2.11.5 shared by UI and API. Existing client bundle-size warning remains; phase status unchanged and concurrent work preserved.
Visual verification for the 2.11.5 no-hit action correction: the shared production component's explicitly synthetic preview was inspected in light and dark; both native buttons remain readable and use the existing control styles. The preview supplies no customer data and does not replace the actual-view interaction acceptance above.
2026-10-02 - Phase 7 / process-arrow clearance correction: current Presentation/Core Tests/Platform release covers this local presentation fix. Fresh desktop geometry already cleared the earlier screenshot's intersections, but browser testing exposed direct upload and source-result paths crossing intervening tiles in the responsive one-column layout. Each proposed orthogonal path now checks every measured tile rectangle with stroke clearance; intersecting paths select a clear measured side rail. Source and destination ports stay 4px outside tile boundaries so triangular tips also clear the tiles. The existing engine arrow style, 17 declared deliveries, yellow connected-edge selection and table popup behavior are retained. Permanent synthetic acceptance selects a finding tile, resizes from unequal desktop lanes to stacked tiles, checks every segment against every tile, checks destination clearance and verifies retained selection. Verified: 17 focused diagram/UI/API version tests passed, security/design checks passed, both production builds passed. Live checks in light and dark at desktop width and in the stacked layout found zero arrow or label intersections. Existing bundle-size warning remains. Version decision: included in the related current 2.11.4 refinement batch already present in the shared workspace; no separate version edit. No API, persistence, engine behavior or phase-status change; concurrent work preserved.
2026-10-02 - Phase 7 / process tile selection and arrow spacing: current Presentation/Core Tests/Platform consent increases processing gaps from 64px to 96px and the shared maximum content width to 1266px, preserving 358px tiles. Clicking a tile surface or its native title button selects/deselects it independently of table detail selection. Selected tiles have the same yellow outline and side marker as engine modules. Every directly connected arrow is highlighted along its complete route, including labels and triangular arrowheads; unrelated arrows remain neutral, matching the engine diagram's connected-edge selection semantics. Consumer routes have more separated label levels. Table chips still open the existing popup and closing it preserves an independently selected tile. Permanent synthetic acceptance covers surface selection, exact connected paths, yellow-marker references, chip navigation, popup closing and deselection. Verified: 10 focused tests, security/design checks and production builds passed; live mouse and Enter-key selection/deselection plus light/dark styling verified. Version decision: same related unreleased 2.11.3 refinement batch, no separate version edit. No data-flow semantics, API, persistence or engine behavior change. Phase status unchanged; existing bundle warning remains.
Explizite Nutzerentscheidung 2026-10-02: Die bestehende Pfeildarstellung in `Engines & relationships` (`src/features/db-structure/EngineDataFlow.tsx`, `.db-engine-flow__edges` und `.db-engine-flow__edge-label` in `src/styles/datahub.css`) ist die verbindliche visuelle Referenz fuer Beziehungspfeile in Diagrammen. Auch der darunterliegende Data Flow uebernimmt diese Darstellung. Beziehungen werden als durchgehende, gerichtete Linien mit klarer dreieckiger Pfeilspitze und lesbarer Beziehungsbeschriftung dargestellt; rechtwinklige Verzweigungen verwenden denselben Stil. Implementierte Beziehungen sind durchgezogen, geplante Beziehungen gestrichelt und ausdruecklich als geplant beschriftet. Tatsaechlich bidirektionale Beziehungen tragen Pfeilspitzen an beiden Enden. Neutrale Linien verwenden `--line-strong`, ausgewaehlte Beziehungen `--marking-color`; Linienstaerke, Marker-Geometrie und Beschriftungsrollen folgen der genannten Referenz. Keine dekorativen Effekte oder neue Palette. Dieser Standard ersetzt ausschliesslich die Pfeildarstellung, nicht die bestehenden Interaktionen oder fachlichen Beziehungstypen anderer Diagramme.
Phase-7-Auftrag, zunaechst als Dokumentationsentscheidung gespeichert und am 2026-10-02 nach erneuter Freigabe umgesetzt: Fehlende sichtbare Beziehungen im unteren Data Flow ergaenzen und bestehende Verbinder an diesen Standard angleichen. Vor der Darstellung jede Verbindung gegen den implementierten Datenfluss pruefen; keine Beziehung allein aus der Kachelposition ableiten. Funktionen bleiben unveraendert. Keine Produktversionsaenderung fuer die urspruengliche Dokumentationsentscheidung.
2026-10-02 - Phase 7 / process relationship arrows: on current Presentation/Core Tests/Platform release, replaced disconnected CSS connector fragments with 17 labelled, directed SVG relationships using the existing engine diagram's 2px lines, 7px triangular markers and 14px labels. Added upload branches, processing chains, baseline input, source-result merge, disclosed-storage-to-Result-DB delivery, direct scanner/policy findings to Work and scanner history to comparison. Other tables remain unconnected because their processing assignment is unknown. Source checks: repository rebuildResultDb source readers, policy/scanner Work projections and compareProjectTimeslotEvidence. No Policy-history arrow: the implemented timeslot comparison consumes scanner runs. DOM geometry and ResizeObserver adapt routes to current tile sizes; merge paths clear taller GPO tiles, side routes bypass consumers, selected table endpoints highlight connected paths. Existing engine overview semantics, popup/table navigation, schema refresh and dictionary retained. Permanent synthetic interaction acceptance now verifies the previously missing relationships, arrow markers, selection/closing invariance and a resize with unequal lane heights. Verified: 10 focused tests passed; security/design checks and both final production builds passed; current-code light/dark browser inspection passed. Existing bundle-size warning remains. Version decision: same related unreleased 2.11.3 refinement batch; no separate version edit. Concurrent backend work untouched, no API/schema/engine behavior change, no phase completion claimed.
2026-10-02 - Phase 7 / shared filter-empty companion and direct reset (2.11.4 patch, implemented and verified): user chose Work, Result DB and Pathfinder, then renewed Presentation/Core Tests/Platform consent. Added an original local SVG companion with a magnifying glass and gentle searching motion to the completed no-hit states. It uses existing neutral theme roles, shared typography/control geometry and an 88px size token; reduced-motion preferences disable animation. It is decorative and hidden from assistive technology; the visible no-hit heading stays authoritative. Shared native Reset filters buttons support mouse and keyboard. Work and Pathfinder clear search, facets, status restrictions, My Workspace-only and path restrictions while preserving the client, timeslot and active sheet. Work retains its distinct Show all results for this source action, including the documented source-preserving behavior. Result DB uses its existing reset contract (Deviation enabled, remaining filters cleared) and preserves its exact project/assessment/scan scope. Pathfinder's empty overlay permits button clicks without intercepting the graph elsewhere. No engine, importer, evaluation, source, storage or disclosure changes; no Data Flow diagram update required.
Verification: 15 focused tests passed across new actual Work/Pathfinder/Result DB no-hit/reset interactions, GPO visibility and source recovery, graph text behavior and Result DB scope. Tests prove results return, all intended restrictions clear, source values stay unchanged and client/timeslot/sheet remain selected. Existing GPO source-action acceptance now identifies that action by its text instead of assuming the first button. Final three interaction tests passed again after a test-only optional-query typing correction. UI/API version contracts passed. Full suite: 1,794 passed, 15 skipped, one known default 5-second node-import-engine timeout; separate default reruns also timed out. Diagnostic rerun with a CLI-only 15-second timeout passed all five tests, with the affected format test taking 4,034ms; permanent acceptance deadlines remain unchanged and the default full suite is not claimed green. Security/design checks and both final production builds passed; existing client bundle-size warning remains. Dummy-client live Work inspection passed in light/dark; Pathfinder reset via Enter restored graph nodes. Shared component screenshots saved as filter-empty-light.png and filter-empty-dark.png. Result DB click verified with synthetic API data and the real component; live Result DB inspection was interrupted by the development backend rebuild/page reload, so no completed live Result DB reset claim. During the earlier live client opening, the separate Context request returned HTTP 500 after about five seconds; Core results opened through existing error handling. That runtime Context failure is an unresolved separate observation, not fixed or certified by this UI batch. App/backend health reports 2.11.4 with PostgreSQL revision 535 unchanged. Version decision: backward-compatible UI refinement, patch 2.11.3 -> 2.11.4; package/lock and UI/API version contracts synchronized. Phase status unchanged; no milestone completion claimed. Concurrent Data Flow/backend changes retained.
Additional visual acceptance fixture for the 2.11.4 empty-state batch: `tests/filter-empty-state-preview.html` renders the shared production component with explicitly synthetic text and no backend reads. Its complete captioned screenshot is `filter-empty-preview.png`; this is a component preview, not a substitute for the real reset-interaction tests described above.
## Verbindliche Erinnerung: Engine-Diagramm bei Data-Flow-Aenderungen
2026-10-02 - Phase 7 / Data Flow layout alignment: current Presentation/Core Tests/Platform consent covers a CSS-only follow-up. Summary, schema actions, engine diagram, inspector, process flow and dictionary now share the diagram's 1130px maximum content width and centered alignment. Reference data starts level with the processing lanes; lane tiles remain centered on their connector spines. Result DB and Work form a compact centered pair. All content, relationships, selections and detail-window behavior retained. Verified: 10 focused layout/flow/interaction tests passed after retaining the existing segment-inset contract; security/design checks, client and server production builds passed, with the client rebuilt after final CSS corrections. Live light/dark inspection confirms identical section width and alignment. Existing bundle-size warning remains. Version decision: included in the current unreleased 2.11.3 refinement batch already present in the shared workspace; no separate version edit. Concurrent backend/performance work was left untouched. Phase status unchanged; no architecture or data-flow semantics changed.
Explizite Nutzerentscheidung 2026-10-02: Bei jeder Umsetzung, die den dargestellten Data Flow betrifft, erinnert der bearbeitende Agent den Nutzer im Abschlussbericht daran, den Auftrag zur Aktualisierung des Engine-Diagramms zu geben. Ausloeser sind neue, entfernte oder geaenderte Engines, Datenquellen, Verarbeitungsschritte, Lese-/Schreibports, Speicherorte, UI-/Exportverbraucher und Beziehungen sowie der Wechsel einer dargestellten Beziehung zwischen geplant und implementiert. Die Erinnerung nennt konkret, welcher Teil der Darstellung betroffen ist.
Die Erinnerung lautet: "Diese Aenderung betrifft den Data Flow (<konkrete Aenderung>). Bitte gib mir den Auftrag: Aktualisiere das Engine-Diagramm."
Wenn die aktuelle Nachricht die Diagrammaktualisierung nicht bereits ausdruecklich beauftragt und die betroffenen Core-Bereiche freigibt, fuegt der Agent die erforderliche Freigabephrase hinzu: "Core-Freigabe: Presentation, Core Tests, Platform". Bestehende ausdrueckliche Auftraege werden innerhalb ihrer aktuellen Freigabe ausgefuehrt; keine doppelte Erinnerung fuer eine bereits erledigte Aktualisierung. Keine automatische Aktualisierung und keine Behauptung, das statische Engine-Diagramm erkenne Architekturveraenderungen selbst. Die bestehende nachrichtenbezogene Core-Freigaberegel bleibt unveraendert.
Dokumentationsentscheidung: dauerhaft im Implementierungsplan gespeichert; keine Code-, Governance-Datei- oder Produktversionsaenderung. Phasenstatus unveraendert.
2026-10-02 - Phase 7/13E / quality-preserving client read optimization (2.11.3 patch, implemented and verified): explicit Store/Platform/Presentation/Core Tests release; data quality takes precedence over speed. PostgreSQL internal-analysis pagination reuses complete source projections within the exact query scope (all arguments except offset/limit). Every call checks the live database revision before and after reading, including cache hits and commits from other backend/worker instances. A revision change discards the read and retries up to three times; repeated changes or an unverifiable revision fail explicitly. Concurrent identical reads share their source load; failed loads are evicted. Retention is process-local, bounded to eight entries and 32 MiB per repository; oversized results remain complete but are not retained. Returned pages and categories are independent copies. Result DB disclosure continues through its existing materialized, disclosure-checked path and never consumes the internal cache. Context additionally reuses full policy evaluations, baseline analyses, assessment lists and models only inside a single revision-checked attempt; retries and new Context runs have fresh read ownership. No schema, migration, persisted source, verdict, score, reference catalogue or engine activation change. Client opening still waits for enabled Context before publishing its workspace; original Raw Data and historical-score loading remain intact.
Verification and limits: the permanent synthetic PostgreSQL paging test compares every field, ordering and count for all 2,501 results across three pages against uncached derivation, with one instead of three full source loads. It covers caller mutation, same-count content changes through a live revision, scope separation and the separate disclosure path. Cache and Context tests cover concurrent reads, bounded retention without truncation, failure/retry and revision-change retry with complete Context equality. Full suite: 1,792 passed, 15 skipped; security and design checks passed. Final focused checks after query-snapshot/read-port tightening: 32 passed, including actual client opening/Context waiting and UI/API version contracts. Both final production builds passed; existing client bundle-size warning remains. App HTTP 200 and backend PostgreSQL health at 2.11.3, repository revision 535. Actual cold-client latency against the reported 80 seconds has not been measured under a controlled identical-client workload; no percentage or time reduction is claimed. Large initial reads and Context computation still run in full, and any repository commit invalidates reuse, including unrelated scopes. Version decision: backward-compatible performance refinement, patch 2.11.2 -> 2.11.3 with package/lock and both version contracts synchronized. Phase status unchanged; no milestone completion claimed. Data Flow follow-up: remind the user to request an Engine diagram update for the revision-checked Core read reuse and request-local Context read step; no automatic diagram edit.
2026-10-02 - Phase 7 / Data Flow process-tile refinement: with current Presentation/Core Tests/Platform consent, adapted the existing processing tiles to the engine tiles' flat neutral surfaces, square geometry and shared side marker. Limited tiles to the existing 358px content width and centered the three processing lanes within that width; retained typography, all source labels, table counts, connectors, table selection, relationship highlights and floating detail windows. Long table names wrap without truncation. CSS only; no data flow, API, engine or persistence changes. Verified: 20 focused flow/layout/interaction and UI/API version tests passed; final security/design checks and both production builds passed. Live inspection passed in light and dark, all 17 tiles had no horizontal content overflow, and the scan_runs detail window opened and closed normally. Existing bundle-size warning remains. Version decision: part of the same related unreleased 2.11.2 presentation refinement batch; no additional increment. Phase status unchanged.
2026-10-02 - Phase 7 / interactive Data Flow diagram (follow-up to the preceding overview): on renewed Presentation/Core Tests/Platform release, replaced the always-open engine table with an interactive architecture diagram. Nine selectable modules include all three engines, source inputs, canonical PostgreSQL, read-only port, UI consumers and report outputs. Nine labelled relationships show directional delivery, bidirectional Core database read/write and the dashed, explicitly Planned Context-to-Report target. Selection highlights connected edges and opens an inspector; native buttons support keyboard activation and deselection. The complete relationship table remains in a closed disclosure. Narrow views scroll the diagram without shrinking 14px information text; inspector stacks. Coordinates live in CSS classes, using current tokens and flat tiles, with no engine imports, requests or data writes. Existing schema flow, table popups and dictionary retained. Version decision: same unreleased, related 2.11.2 refinement batch; no additional version increment. Permanent synthetic tests cover module selection, deselection, directed/bidirectional markers, planned output and disclosure; 20 focused tests passed before the final CSS correction, and 14 final diagram/layout/import tests passed afterward. Full suite: 1783 passed, 15 skipped, one unrelated 5-second timeout in node-import-engine; all five tests in that unchanged suite passed in the separate rerun (format test 3504ms). Security/design checks passed. Both production builds passed; final client rebuild after the CSS-class and read/write-marker corrections also passed. Existing bundle-size warning remains. Live mouse/keyboard selection, both themes and horizontal overflow checked. Phase status unchanged; planned report Context integration remains outside this UI change.
2026-10-02 - Phase 7 / Data Flow engine overview (2.11.2 patch): renamed DB Structure to Data Flow in navigation and header. Added Core, Context and Report with nine directed data-delivery relationships, canonical PostgreSQL, read-only Core port, UI consumers and separate report archive. Context-to-Report is explicitly Planned: current report generation does not consume published Context results. Architecture includes disabled engines. Corrected obsolete Result DB wording and assigned result_db_rows/result_db_state to Core-owned disclosure materialization before its consumer. Existing table details preserved. No API, persistence or engine behavior changes. Verified: 1783 tests passed, 15 skipped; UI/API version contracts, architecture and engine invariance passed; security/design checks and both production builds passed, client build repeated after final order correction. Live inspection in both themes; backend healthy at 2.11.2 with PostgreSQL. Existing bundle warning remains. Version decision: backward-compatible refinement, patch 2.11.1 -> 2.11.2, package/lock synchronized. Phase status unchanged. Follow-up request for a more futuristic diagram awaits renewed Core consent under the per-message rule.
@ -2063,6 +2467,17 @@ Abnahmekriterien:
- Betrieb, Wiederherstellung und Update sind dokumentiert und getestet.
- Es findet keine Übertragung an externe Dienste statt, sofern dies nicht später ausdrücklich beschlossen wird.
#### Spaeteres Arbeitspaket: automatische Security-Baseline-Updates
Nutzerentscheidung 2026-10-02: fuer spaeter vormerken, jetzt nicht implementieren. Zuordnung: Phase 13B, mit Backend-Betrieb aus Phase 10 und Kataloganzeige aus Phase 7. Status: geplant / zurueckgestellt; keine automatische Pruefung, kein Download und kein Zeitplan aktiviert.
- Ziel: Das Backend kann regelmaessig nach neuen Security-Baseline-Versionen suchen, sie herunterladen und ueber den bestehenden validierten Importweg als eigene Version aufnehmen. Der manuelle ZIP-Upload bleibt verfuegbar.
- Oberste Praemisse: Datenqualitaet vor Geschwindigkeit. Vor Uebernahme sind Herkunft aus festgelegten offiziellen Quellen, eindeutige Version, unterstuetztes Format und Vollstaendigkeit zu pruefen. Originalartefakt, Herkunft, Pruefsumme und Validierungsergebnis muessen nachvollziehbar erhalten bleiben.
- Fehler, unvollstaendige Pakete, unklare Versionen und nicht erreichbare Quellen duerfen die letzte gueltige Baseline nicht ersetzen. Gleiche Versionen duerfen keine doppelten Katalogeintraege erzeugen; neue Versionen bleiben getrennt von vorhandenen Releases.
- Bestehende Auswertungen und ihre Baseline-Zuordnung werden durch ein Update nicht automatisch ueberschrieben oder neu bewertet. Eine erneute Auswertung bleibt ein gesonderter Vorgang ueber den bestehenden kontrollierten Pfad.
- Vor Umsetzung klaeren: unterstuetzte Hersteller/Produkte und offizielle Downloadquellen, Pruefintervall, Proxy-/Offline-Betrieb sowie sichtbare Update- und Fehlerhinweise. Quellenwechsel und Formatdrift sind Risiken; die Update-Pruefung darf keine Mandantendaten an externe Dienste uebertragen.
- Spaetere Abnahme mit synthetischen Quellen/Paketen: gueltige neue Version, identische Version, falsche Herkunft, unklare Version, unvollstaendiges oder ungueltiges Paket und Netzwerkfehler; jeweils unveraenderte vorhandene Baselines und historische Auswertungen nachweisen. Umsetzung erfordert eine neue Freigabe der tatsaechlich betroffenen Core-Bereiche; diese Planaufnahme ist keine Implementierungsfreigabe.
### Phase 13D - Referenzerkennung und katalogbasierte Einordnung
2026-09-26 - Phase 13D / gemischte Work-Projektion (2.8.5, umgesetzt und verifiziert): Beim Anhaengen von GPO-Ergebnissen an All Records wird referenceRecognition jetzt vom jeweiligen Scannerbefund uebernommen. Zuvor blieben die Referenzlaeufe gespeichert, gingen aber beim Neuaufbau der Reader-Projektion verloren. Die Uebernahme bewahrt Referenzen, Quellenherkunft, Erkennerversion, Lauf und Katalogversion/-hash einschliesslich bewusst leerer Erkennung; GPOs und nicht indexierte Befunde erhalten keine fremden Referenzen. Synthetische Regression oeffnet Referenzdetails im echten FindingReader mit und ohne GPOs. 30 fokussierte Referenz-/GPO-/CIS-Tests und 9 UI-/API-Versionsvertragstests, beide Produktionsbuilds sowie Design-/Local-only- und Diffpruefung bestanden. Patchversion fuer rueckwaertskompatible Projektionskorrektur; keine Datenmutation und kein Reimport bzw. neuer Erkennungslauf erforderlich. Bereits geladene Workspaces muessen neu geladen werden. Phase 13D bleibt in Arbeit.
@ -2896,6 +3311,9 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll
2026-10-02 - Nutzerwunsch fuer spaeter dokumentiert: automatische Security-Baseline-Updates als zurueckgestelltes Arbeitspaket in Phase 13B, mit Phase-10-Backend und Phase-7-Anzeige. Datenqualitaet, Herkunft/Version/Format/Vollstaendigkeit, Erhalt der letzten gueltigen Baseline und unveraenderte historische Auswertungen sind Abnahmekriterien; Quellen und Intervall bleiben offen. Ausschliesslich Planaufnahme, keine Implementierung, Automatisierung oder Produktversionsaenderung.
2026-10-01 - Phase 13E / Vokabular anhand zweier Result-DB-Exporte des Nutzers erweitert (Engine-Arbeit, per Test verifiziert): Der Nutzer stellte zwei gefilterte Result-DB-Exporte (zusammen 953 Abweichungen) zur Analyse bereit; gelesen wurden Tool, Regel-ID und Regeltext, nichts davon wurde gespeichert oder in Code oder Tests uebernommen. Vorher blieben ueber Katalog und Vokabular 498 offen (GPO 449, Purple Knight 22, PingCastle 15, Maester 12), danach 26 (14, 7, 2, 3; die Simulation beruecksichtigt keine GPO-Katalogregeln, Anker, Aehnlichkeit oder Kategorien, die echte Engine liegt darunter). Klarstellung: `purple-knight-ad`/`-entra` sind nur Bezeichnungen der Result-DB-Projektion; die Engine liest die interne Tool-ID `purple-knight`, die Katalogregeln greifen. Ergaenzt: Kontorichtlinien-Schluessel (Password Guessing), Benutzerrechte `Se...Privilege` (Host Credential), Anmelderechte und DCOM-Gruppe (Lateral Movement), `SeEnableDelegationPrivilege` (Delegation), UAC-Werte, gespeicherte Browser-Passwoerter, Druckertreiber-Installation (Host Credential), LAPS/`AdmPwd`, NTLM-Audit-Werte und Alerts (Detection), Office-Sicherheitspfade, Internet Settings/ZoneMap (Malicious Code), Bildschirmsperre (Physical), AD-Berechtigungsdelegation und Kontrollpfade (AD Privileged), Aenderungsindikatoren (AD Persistence), Seamless SSO, Security Defaults und Modern Authentication (Cloud Sign-in), App-Registrierung (App Consent), Skype-Foederation und Speicheranbieter (External Access), MailTips (E-Mail), Customer Lockbox (Data Sharing), RMAU und SSPR fuer privilegierte Rollen (Cloud Privileged), NTFRS und nicht unterstuetzte Windows-Versionen (Patch Drift); Pfadmuster greifen auch am Schluesselende. Vertrauenswuerdige Standorte nur im Office-Sicherheitspfad, weil Conditional Access den Begriff anders nutzt. Aenderungsindikatoren werden jetzt AD Persistence zugeordnet (vorher bewusst offen). Praezision an allen 332 Katalogtexten 100 %, alle katalogisierten Registry-Ziele widerspruchsfrei. `npm test` gruen (1.774), Typpruefung, beide Produktionsbuilds. Server-Neustart noetig. In 2.10.0 gebuendelt.
2026-10-01 - Phase 13E / Registry-Policy-Bereiche im Vokabular (Engine-Arbeit, per Test verifiziert): Anlass: Eine GPO-Abweichung unter `...\Internet Settings\Zones\2\1201` (Internet-Explorer-Zonenaktion, ActiveX) blieb trotz vollstaendiger Raw Data in `Other Deviations`, weil das Vokabular keine Begriffe fuer diesen Policy-Bereich kannte. Ergaenzt um Registry-Pfadsegmente und Wertnamen der Microsoft-Baselines, die genau einen Angriffsweg benennen: IE-Zonen/ActiveX/URL-Aktionen, Defender-Pfade, Makro-Werte, Attachment Manager, Safer/SrpV2/AppLocker/WDAC (Malicious Code); `WindowsFirewall` (Lateral Movement) und deren `Logging` (Detection); PowerShell-Protokollierung (Detection, ohne ExecutionPolicy); Lanman, Hardened Paths, DNS-Client, Drucker, NetBT (Credential Relay); CredentialsDelegation, DeviceGuard, Installer, LSA-Schutz (Host Credential); DataCollection/CloudContent/Telemetrie (Data Sharing); WindowsUpdate (Patch Drift). Ganze Produktbaeume wie Office oder Edge werden bewusst nicht zugeordnet, weil der Katalog selbst eine Office-Einstellung Host Credential zuordnet. Messung: alle katalogisierten Registry-Ziele ohne Widerspruch (10 von 11 erkannt, die Office-Einstellung bewusst nicht), alle 332 Katalogtexte weiter 100 % praezise, Abdeckung 73,2 %. Dauertests fuer den Zonen-Pfad und den Abgleich mit den katalogisierten Registry-Zielen. `npm test` gruen (1.772), Typpruefung, beide Produktionsbuilds. Server-Neustart noetig. In 2.10.0 gebuendelt.

View File

@ -1,5 +1,23 @@
# Assessment output strategy
## Export layout decision 2026-10-02
User approval 2026-10-02: this implemented report is the binding template for future Management Reports. Its permanent contract lives inside Report Engine at [MANAGEMENT_REPORT_TEMPLATE.md](../src/reporting/MANAGEMENT_REPORT_TEMPLATE.md). Preserve the approved renderer structure and data boundaries unless explicitly revised by the user; historical HTML exports are not overwritten.
The user-supplied Huntsman report page is the layout/style reference for the Management Report only: white report sheets, ASSESSOR branding, orange headings, dark table headers, two-column category explanations/evidence, factual bar charts and repeated footers. This explicit export-only decision supersedes previous app-style layout guidance for the Management Report; application views remain unchanged. The report embeds the existing ASSESSOR logo and local Inter with no remote dependency. The opening overview and complete Context heatmap lead into one logical report sheet per populated category, followed by scope and method. Logical sheet numbering does not promise one physical PDF page per sheet. Missing time-series/score/endpoint data is never fabricated to imitate the reference. Original technical evidence and recommendations remain in the separate Technical Report.
## Management Report with published Context results (2026-10-02)
Explicit user decision supersedes the earlier Core-only Report A content below: the Management Report requires Context results for the same client, assessments, scan runs and committed repository revision. It is generated offline with fixed German text, no LLM and no Internet access. A missing Context producer or mismatched scope blocks the management run; a changing revision retries the read and ultimately fails rather than combining snapshots. Client Info refuses management generation while Context Engine is off and suppresses late output after it is switched off. Technical generation remains available independently and does not compute Context when invoked alone; its archive contains no management HTML rather than a Core-only fallback. Historical frozen projections retain the historical renderer and are not rewritten.
Context Engine publishes a versioned result object through `context-report-publication.ts`; Report Engine consumes only its declared `ReportContextPublication` contract. The Core backend composes the two at its existing pinned extension point using the canonical read-only port. Neither engine imports the other. The publication preserves catalog/vocabulary/engine/severity versions, deduplicated finding count, per-cluster scanner/GPO counts, assignment confidence, source provenance and technical Result IDs. It uses the existing Context membership and heatmap semantics, without classifying or scoring again.
The management file contains: Management Summary; all Context categories in the severity-sorted heatmap with the unique overall Finding count; populated problem fields with general catalog descriptions, evidence counts, confidence and scanner sources; fixed review guidance pointing to original recommendations in Report B; documented risk acceptance and selected work-state counts; comparison results only when supplied by the existing shared projection; environment facts, source coverage and collection times; technical Security Domain counts in the appendix; method and source-version notes. General catalog descriptions are explicitly not evidence of a common attack path. Base Severity is category severity, never a new finding verdict, CVSS, priority, exploitation probability or business loss. Unknown/unexecuted checks are not passed; neutral heatmap cells are not secure; GPO backup configuration is not effective host state. Overlapping cluster counts are never added into a total. Work tracking and risk acceptance do not prove remediation; rescans remain required.
Management disclosure stays separate: no Result IDs, source Rule IDs, evidence paths, registry keys, technical finding fields or original scanner remediation payloads are hidden in Report A. Report B receives the frozen Context counts and the Result IDs proving them alongside its original detailed evidence and recommendations. Archive projection JSON retains the published results for reproducible replay. Static HTML includes the already bundled local Inter font as embedded data and has no script or external resources. The report export is not part of the client load path.
Current versions: Report Engine 0.3.0, shared projection 1.3.0, Management renderer 3.0.0; product capability release 2.13.0. No database schema, migration or Core result semantics change. A separate historical Delta Report remains its own artifact; the new management cut does not invent comparison evidence when no delta was supplied.
Decision: 2026-09-18, explicitly approved by the user. The 2026-09-23 user request
starts the A/B HTML deliverable through Client Info (Phase 7 UI integration,
cross-cutting report projection). Artifact C and D remain separate future work.

View File

@ -0,0 +1,231 @@
# Context: fehlende Pass-Regeln (Client Dummy)
Stand: 2026-10-04 · Context Engine 1.10.0 · Katalog 1.9.1 · Scope: alle drei Dummy-Timeslots
Analyse-Liste, keine Katalogänderung. Aufgeführt ist jede Regel, deren Pass-Ergebnis laut Scan-Bericht in keiner Context-Bedingung vorkommt. Abweichungen sind vollständig katalogisiert; diese Lücke betrifft nur Pass-Belege. PingCastle fehlt, weil der Bericht keine Pass-Ergebnisse enthält.
Eine Regel gehört erst nach geprüfter Zuordnung zu einem Cluster in den Katalog: exakte Regel-ID mit Polarität `fail-means-met`, bei CIS-CAT der Berichtsabschnitt. Keine Heuristik.
## Übersicht
| Quelle | Pass-Regeln gesamt | davon im Context | fehlend |
|---|---|---|---|
| Purple Knight | 96 | 20 | 76 |
| Maester | 74 | 8 | 66 |
| CIS-CAT | 87 | 54 | 33 |
| GPO (Einstellungen) | 25 | 7 | 18 |
## Purple Knight
| Regel-ID | Titel | Severity (Quelle) | Kategorie (Quelle) |
|---|---|---|---|
| `SI000013` | Evidence of Mimikatz DCShadow attack | Critical | AD Infrastructure Security |
| `SI000014` | Users and computers with non-default Primary Group IDs | Low | Account Security |
| `SI000015` | Reversible passwords found in GPOs | Critical | Group Policy Security |
| `SI000017` | Kerberos KRBTGT account with old password | Medium | Kerberos Security |
| `SI000018` | Non default value on ms-Mcs-AdmPwd SearchFlags | High | AD Delegation |
| `SI000021` | Non-default principals with DC Sync rights on the domain | Critical | AD Delegation |
| `SI000022` | Risky RODC credential caching | Medium | AD Infrastructure Security |
| `SI000023` | Well-known privileged SIDs in SIDHistory | Critical | AD Infrastructure Security |
| `SI000025` | Privileged objects with unprivileged owners | High | AD Delegation |
| `SI000027` | Users with Kerberos pre-authentication disabled | Medium | Account Security |
| `SI000032` | GPO linking delegation at the domain level | High | Group Policy Security |
| `SI000033` | Recent privileged account creation activity | Low | Account Security |
| `SI000037` | Users with old passwords | Low | Account Security |
| `SI000038` | Enabled admin accounts that are inactive | Medium | Account Security |
| `SI000039` | Privileged users that are disabled | Low | Account Security |
| `SI000040` | GPO linking delegation at the domain controller OU level | High | Group Policy Security |
| `SI000041` | GPO linking delegation at the AD Site level | High | Group Policy Security |
| `SI000042` | Users with SPN defined | Low | Kerberos Security |
| `SI000043` | Changes to privileged group membership in the last 7 days | Medium | Account Security |
| `SI000044` | AD objects created within the last 10 days | Informational | Account Security |
| `SI000046` | Objects in privileged groups without adminCount=1 (SDProp) | Medium | AD Delegation |
| `SI000047` | Changes to default security descriptor schema in the last 90 days | High | AD Delegation |
| `SI000048` | Changes to Default Domain Policy or Default Domain Controllers Policy in the last 7 days | Medium | Group Policy Security |
| `SI000049` | Built-in domain Administrator account with old password (180 days) | Medium | Account Security |
| `SI000050` | Domain Controller owner is not an administrator | High | AD Delegation |
| `SI000051` | Domains with obsolete functional levels | Low | AD Infrastructure Security |
| `SI000053` | Operator groups no longer protected by AdminSDHolder and SDProp | Medium | AD Infrastructure Security |
| `SI000054` | Computers with older OS versions | Medium | AD Infrastructure Security |
| `SI000056` | Enterprise Key Admins with full access to domain | High | AD Delegation |
| `SI000057` | Objects with constrained delegation configured | Medium | Kerberos Security |
| `SI000058` | Kerberos protocol transition delegation configured | High | Kerberos Security |
| `SI000059` | Computers with password last set over 90 days ago | High | AD Infrastructure Security |
| `SI000060` | Recent SIDHistory changes on objects | Medium | Account Security |
| `SI000061` | Computer Accounts in Privileged Groups | High | Account Security |
| `SI000062` | Admins with old passwords | Low | Account Security |
| `SI000063` | Domain Controllers with old passwords | Low | AD Infrastructure Security |
| `SI000064` | Forest contains more than 50 privileged accounts | Medium | Account Security |
| `SI000068` | Trust accounts with old passwords | Low | Account Security |
| `SI000069` | Domain Controllers in inconsistent state | Low | AD Infrastructure Security |
| `SI000072` | gMSA objects with old passwords | High | AD Infrastructure Security |
| `SI000073` | Domain Controllers that have not authenticated to the domain for more than 45 days | Medium | AD Infrastructure Security |
| `SI000074` | User accounts with password not required | High | Account Security |
| `SI000075` | User accounts that use DES encryption | Medium | Account Security |
| `SI000076` | User accounts that store passwords with reversible encryption | Medium | Account Security |
| `SI000078` | Users and computers without readable PGID | Medium | Account Security |
| `SI000081` | Non-standard schema permissions | High | AD Infrastructure Security |
| `SI000082` | Changes to AD Display Specifiers in the past 90 days | Low | AD Delegation |
| `SI000083` | Non-privileged users with access to gMSA passwords | High | AD Delegation |
| `SI000086` | Weak certificate cipher | Critical | AD Infrastructure Security |
| `SI000087` | Domain trust to a third-party domain without quarantine | Medium | AD Infrastructure Security |
| `SI000088` | Outbound forest trust with SID History enabled | High | AD Infrastructure Security |
| `SI000089` | Users with permissions to set Server Trust Account | Critical | AD Delegation |
| `SI000092` | Non-default access to DPAPI key | High | AD Delegation |
| `SI000097` | NTFRS SYSVOL Replication | High | AD Infrastructure Security |
| `SI000098` | Abnormal Password Refresh | Medium | Account Security |
| `SI000100` | Accounts with Constrained Delegation configured to ghost SPN | High | Kerberos Security |
| `SI000101` | SYSVOL Executable Changes | Low | Group Policy Security |
| `SI000102` | Accounts with altSecurityIdentities configured | High | Kerberos Security |
| `SI000103` | FGPP not applied to Global group | Medium | Account Security |
| `SI000106` | Inheritance enabled on AdminSDHolder object | Critical | AD Delegation |
| `SI000108` | Dangerous Trust Attribute Set | High | AD Infrastructure Security |
| `SI000110` | Ephemeral Admins | Low | Account Security |
| `SI000124` | Foreign Security Principals in Privileged Group | Medium | AD Delegation |
| `SI000138` | Privileged user credentials cached on RODC | Medium | AD Infrastructure Security |
| `SI000150` | Operators Groups that are not empty | High | Account Security |
| `SI000185` | Objects with Reanimate-Tombstones extended right | Medium | AD Delegation |
| `SI000188` | GPO Weak LM Hash storage enabled | High | Group Policy Security |
| `SI000189` | Writable shortcuts found in GPO | High | Group Policy Security |
| `SI000193` | OU permissions enabling BadSuccessor dMSA escalation | High | Account Security |
| `SI000217` | Query policies that have the attribute of LDAP deny list set | Medium | AD Infrastructure Security |
| `SI000236` | User accounts using Smart Card authentication with old password | High | Account Security |
| `SI000304` | Dangerous GPO logon script path | High | Group Policy Security |
| `SI000314` | Unexpected accounts in Cert Publishers Group | High | AD Infrastructure Security |
| `SI000316` | GPO with Scheduled Tasks configured | Low | Group Policy Security |
| `SI000339` | Users with the attribute userPassword set | Medium | Kerberos Security |
| `SI000355` | Distributed COM Users group or Performance Log Users group are not empty | High | Account Security |
## Maester
| Regel-ID | Titel | Severity (Quelle) | Kategorie (Quelle) |
|---|---|---|---|
| `CIS.M365.1.3.1` | (L1) Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)' | High | ["CIS.M365.1.3.1","L1","CIS E3 Level 1","CIS E3","CIS","Security","All","CIS M365 v4.0.0"], CIS, CIS.M365.1.3.1, L1, CIS E3 Level 1, CIS E3, CIS, Security, CIS M365 v5.0.0 |
| `CISA.MS.AAD.2.1` | Users detected as high risk SHALL be blocked. | High | ["MS.AAD","MS.AAD.2.1","CISA.MS.AAD.2.1","CISA","Security","All","Entra ID P2"], CISA, MS.AAD, MS.AAD.2.1, CISA.MS.AAD.2.1, CISA, Security, Entra ID P2 |
| `CISA.MS.AAD.2.2` | A notification SHOULD be sent to the administrator when high-risk users are detected. | High | ["MS.AAD","MS.AAD.2.2","CISA.MS.AAD.2.2","CISA","Security","All","Entra ID P2"], CISA, MS.AAD, MS.AAD.2.2, CISA.MS.AAD.2.2, CISA, Security, Entra ID P2 |
| `CISA.MS.AAD.3.2` | If phishing-resistant MFA has not been enforced, an alternative MFA method SHALL be enforced for all users. | High | ["MS.AAD","MS.AAD.3.2","CISA.MS.AAD.3.2","CISA","Security","All","Entra ID P1"], CISA, MS.AAD, MS.AAD.3.2, CISA.MS.AAD.3.2, CISA, Security, Entra ID P1 |
| `CISA.MS.AAD.3.4` | The Authentication Methods Manage Migration feature SHALL be set to Migration Complete. | High | ["MS.AAD","MS.AAD.3.4","CISA.MS.AAD.3.4","CISA","Security","All","Entra ID P1"], CISA, MS.AAD, MS.AAD.3.4, CISA.MS.AAD.3.4, CISA, Security, Entra ID P1 |
| `CISA.MS.AAD.5.1` | Only administrators SHALL be allowed to register applications. | High | ["MS.AAD","MS.AAD.5.1","CISA.MS.AAD.5.1","CISA","Security","All","Entra ID Free"], CISA, MS.AAD, MS.AAD.5.1, CISA.MS.AAD.5.1, CISA, Security, Entra ID Free |
| `CISA.MS.AAD.6.1` | User passwords SHALL NOT expire. | High | ["MS.AAD","MS.AAD.6.1","CISA.MS.AAD.6.1","CISA","Security","All","Entra ID Free"], CISA, MS.AAD, MS.AAD.6.1, CISA.MS.AAD.6.1, CISA, Security, Entra ID Free |
| `CISA.MS.AAD.7.5` | Provisioning users to highly privileged roles SHALL NOT occur outside of a PAM system. | High | ["MS.AAD","MS.AAD.7.5","CISA.MS.AAD.7.5","CISA","Security","All","Entra ID P2"], CISA, MS.AAD, MS.AAD.7.5, CISA.MS.AAD.7.5, CISA, Security, Entra ID P2 |
| `CISA.MS.AAD.7.6` | Activation of the Global Administrator role SHALL require approval. | High | ["MS.AAD","MS.AAD.7.6","CISA.MS.AAD.7.6","CISA","Security","All","Entra ID P2"], CISA, MS.AAD, MS.AAD.7.6, CISA.MS.AAD.7.6, CISA, Security, Entra ID P2 |
| `CISA.MS.AAD.7.8` | User activation of the Global Administrator role SHALL trigger an alert. | High | ["MS.AAD","MS.AAD.7.8","CISA.MS.AAD.7.8","CISA","Security","All","Entra ID P2"], CISA, MS.AAD, MS.AAD.7.8, CISA.MS.AAD.7.8, CISA, Security, Entra ID P2 |
| `CT001` | User device quota should be set to 10 | — | Custom Security Tests, Custom, Security, Device |
| `EIDSCA.AF01` | Authentication Method - FIDO2 security key - State. | High | ["EIDSCA","Security","All","EIDSCA.AF01"], EIDSCA, EIDSCA, Security, EIDSCA.AF01 |
| `EIDSCA.AF02` | Authentication Method - FIDO2 security key - Allow self-service set up. | Medium | ["EIDSCA","Security","All","EIDSCA.AF02"], EIDSCA, EIDSCA, Security, EIDSCA.AF02 |
| `EIDSCA.AG01` | Authentication Method - General Settings - Manage migration. | High | ["EIDSCA","Security","All","EIDSCA.AG01"], EIDSCA, EIDSCA, Security, EIDSCA.AG01 |
| `EIDSCA.AG03` | Authentication Method - General Settings - Report suspicious activity - Included users/groups. | Medium | ["EIDSCA","Security","All","EIDSCA.AG03"], EIDSCA, EIDSCA, Security, EIDSCA.AG03 |
| `EIDSCA.AM01` | Authentication Method - Microsoft Authenticator - State. | High | ["EIDSCA","Security","All","EIDSCA.AM01"], EIDSCA, EIDSCA, Security, EIDSCA.AM01 |
| `EIDSCA.AM03` | Authentication Method - Microsoft Authenticator - Require number matching for push notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM03"], EIDSCA, EIDSCA, Security, EIDSCA.AM03 |
| `EIDSCA.AM04` | Authentication Method - Microsoft Authenticator - Included users/groups of number matching for push notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM04"], EIDSCA, EIDSCA, Security, EIDSCA.AM04 |
| `EIDSCA.AM06` | Authentication Method - Microsoft Authenticator - Show application name in push and passwordless notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM06"], EIDSCA, EIDSCA, Security, EIDSCA.AM06 |
| `EIDSCA.AM07` | Authentication Method - Microsoft Authenticator - Included users/groups to show application name in push and passwordless notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM07"], EIDSCA, EIDSCA, Security, EIDSCA.AM07 |
| `EIDSCA.AM09` | Authentication Method - Microsoft Authenticator - Show geographic location in push and passwordless notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM09"], EIDSCA, EIDSCA, Security, EIDSCA.AM09 |
| `EIDSCA.AM10` | Authentication Method - Microsoft Authenticator - Included users/groups to show geographic location in push and passwordless notifications. | Medium | ["EIDSCA","Security","All","EIDSCA.AM10"], EIDSCA, EIDSCA, Security, EIDSCA.AM10 |
| `EIDSCA.AP01` | Default Authorization Settings - Enabled Self service password reset for administrators. | High | ["EIDSCA","Security","All","EIDSCA.AP01"], EIDSCA, EIDSCA, Security, EIDSCA.AP01 |
| `EIDSCA.AP08` | Default Authorization Settings - User consent policy assigned for applications. | Medium | ["EIDSCA","Security","All","EIDSCA.AP08"], EIDSCA, EIDSCA, Security, EIDSCA.AP08 |
| `EIDSCA.AP10` | Default Authorization Settings - Default User Role Permissions - Allowed to create Apps. | High | ["EIDSCA","Security","All","EIDSCA.AP10"], EIDSCA, EIDSCA, Security, EIDSCA.AP10 |
| `EIDSCA.AP14` | Default Authorization Settings - Default User Role Permissions - Allowed to read other users. | High | ["EIDSCA","Security","All","EIDSCA.AP14"], EIDSCA, EIDSCA, Security, EIDSCA.AP14 |
| `EIDSCA.AS04` | Authentication Method - SMS - Use for sign-in. | High | ["EIDSCA","Security","All","EIDSCA.AS04"], EIDSCA, EIDSCA, Security, EIDSCA.AS04 |
| `EIDSCA.AT01` | Authentication Method - Temporary Access Pass - State. | High | ["EIDSCA","Security","All","EIDSCA.AT01"], EIDSCA, EIDSCA, Security, EIDSCA.AT01 |
| `EIDSCA.AT02` | Authentication Method - Temporary Access Pass - One-time. | High | ["EIDSCA","Security","All","EIDSCA.AT02"], EIDSCA, EIDSCA, Security, EIDSCA.AT02 |
| `EIDSCA.AV01` | Authentication Method - Voice call - State. | High | ["EIDSCA","Security","All","EIDSCA.AV01"], EIDSCA, EIDSCA, Security, EIDSCA.AV01 |
| `EIDSCA.PR02` | Default Settings - Password Rule Settings - Password Protection - Enable password protection on Windows Server Active Directory. | High | ["EIDSCA","Security","All","EIDSCA.PR02"], EIDSCA, EIDSCA, Security, EIDSCA.PR02 |
| `EIDSCA.ST08` | Default Settings - Classification and M365 Groups - M365 groups - Allow Guests to become Group Owner. | Medium | ["EIDSCA","Security","All","EIDSCA.ST08"], EIDSCA, EIDSCA, Security, EIDSCA.ST08 |
| `EIDSCA.ST09` | Default Settings - Classification and M365 Groups - M365 groups - Allow Guests to have access to groups content. | Medium | ["EIDSCA","Security","All","EIDSCA.ST09"], EIDSCA, EIDSCA, Security, EIDSCA.ST09 |
| `MT.1001` | At least one Conditional Access policy is configured with device compliance. | Medium | ["Maester","CA","Security","All","MT.1001"], Maester/Entra, Maester, CA, Security, MT.1001 |
| `MT.1002` | App management restrictions on applications and service principals is configured and enabled. | High | ["Maester","App","Security","All","MT.1002"], Maester/Entra, Maester, App, Security, MT.1002 |
| `MT.1003` | At least one Conditional Access policy is configured with All Apps. | High | ["Maester","CA","Security","All","MT.1003"], Maester/Entra, Maester, CA, Security, MT.1003 |
| `MT.1004` | At least one Conditional Access policy is configured with All Apps and All Users. | High | ["Maester","CA","Security","All","MT.1004"], Maester/Entra, Maester, CA, Security, MT.1004 |
| `MT.1006` | At least one Conditional Access policy is configured to require MFA for admins. | High | ["Maester","CA","Security","All","MT.1006"], Maester/Entra, Maester, CA, Security, MT.1006 |
| `MT.1007` | At least one Conditional Access policy is configured to require MFA for all users. | High | ["Maester","CA","Security","All","MT.1007"], Maester/Entra, Maester, CA, Security, MT.1007 |
| `MT.1008` | At least one Conditional Access policy is configured to require MFA for Azure management. | High | ["Maester","CA","Security","All","MT.1008"], Maester/Entra, Maester, CA, Security, MT.1008 |
| `MT.1013` | At least one Conditional Access policy is configured to require new password when user risk is high. | High | ["Maester","CA","Security","All","MT.1013"], Maester/Entra, Maester, CA, Security, MT.1013 |
| `MT.1016` | At least one Conditional Access policy is configured to require MFA for guest access. | High | ["Maester","CA","Security","All","MT.1016"], Maester/Entra, Maester, CA, Security, MT.1016 |
| `MT.1022` | All users utilizing a P1 license should be licensed. | Medium | ["Entra","License","MT.1022"], Maester/Entra, Entra, License, MT.1022 |
| `MT.1023` | All users utilizing a P2 license should be licensed. | Medium | ["Entra","License","MT.1023"], Maester/Entra, Entra, License, MT.1023 |
| `MT.1024.oneAdmin` | Designate more than one global admin. | Low | Maester/Entra, Maester, Entra, Security, Recommendation, MT.1024, oneAdmin |
| `MT.1024.pwagePolicyNew` | Do not expire passwords. | High | Maester/Entra, Maester, Entra, Security, Recommendation, MT.1024, pwagePolicyNew |
| `MT.1024.roleOverlap` | Use least privileged administrative roles . | Low | Maester/Entra, Maester, Entra, Security, Recommendation, MT.1024, roleOverlap |
| `MT.1024.selfServicePasswordReset` | Enable self-service password reset. | Low | Maester/Entra, Maester, Entra, Security, Recommendation, MT.1024, selfServicePasswordReset |
| `MT.1024.servicePrincipalKeyExpiry` | Renew expiring service principal credentials. | High | Maester/Entra, Maester, Entra, Security, Recommendation, MT.1024, servicePrincipalKeyExpiry |
| `MT.1026` | No hybrid user with permanent role assignment on Control Plane. | High | ["Maester","Privileged","Security","All","MT.1026"], Maester/Entra, Maester, Privileged, Security, MT.1026 |
| `MT.1029` | Stale accounts are not assigned to privileged roles. | High | ["Privileged","Security","All","PIM","MT.1029"], Maester/Entra, Privileged, Security, PIM, MT.1029 |
| `MT.1031` | Privileged role on Control Plane are managed by PIM only. | High | ["Privileged","Security","All","PIM","MT.1031"], Maester/Entra, Privileged, Security, PIM, MT.1031 |
| `MT.1035` | All security groups assigned to Conditional Access Policies should be protected by RMAU. | High | ["Maester","CA","Security","All","MT.1035"], Maester/Entra, Maester, CA, Security, MT.1035 |
| `MT.1049` | Conditional Access policies for User Risk and Sign-in Risk should be configured separately. | High | ["Maester","CA","Security","All","MT.1049"], Maester/Entra, Maester, CA, Security, MT.1049 |
| `MT.1052` | At least one Conditional Access policy is targeting the Device Code authentication flow. | High | ["Maester","CA","Security","All","MT.1052"], Maester/Entra, Maester, CA, Security, MT.1052 |
| `MT.1055` | Microsoft 365 Group (and Team) creation should be restricted to approved users. | Medium | ["Maester","Group","Security","All","MT.1055"], Maester/Entra, Maester, Group, Security, MT.1055 |
| `MT.1059.d41d8cd98f00b204e9800998ecf8427e` | MDI Health Issues - . | — | Defender for Identity health issues, Maester, Defender, Security, MDI, MT.1059, Severity:, |
| `MT.1061` | Device registration MFA control conflicts with Conditional Access policies. | Medium | Maester/Entra, Maester, CA, Security, MT.1061 |
| `MT.1065` | Ensure all Recovery Services Vaults have soft delete enabled | High | AzureConfig, Backup, Azure, MT.1065 |
| `MT.1067` | Authentication method policies should not reference non-existent groups. | Medium | Maester/Entra, Maester, Authentication, Security, MT.1067 |
| `MT.1068` | Restrict non-admin users from creating tenants. | Medium | Maester/Entra, Governance, Entra, Security, MT.1068 |
| `MT.1069` | Restrict non-admin users from creating security groups. | Low | Maester/Entra, Governance, Entra, Security, MT.1069 |
| `MT.1070` | Restrict device join to selected users/groups or none. | Medium | Maester/Entra, Governance, Entra, Security, MT.1070 |
| `MT.1072` | Conditional access policies should not use the deprecated Approved Client App grant. | High | Maester/Entra, Maester, CA, Security, MT.1072 |
| `MT.1074` | Ensure no more then 100 outbound mails per day are send using the .onmicrosoft.com domain | Medium | Maester/Exchange, Maester, Exchange, MT.1074 |
| `MT.1085` | Pending approvals for Critical Asset Management should not be present. | Medium | Exposure Management, Entra, Graph, Security, XSPM, MT.1085 |
## CIS-CAT
Abschnitt = erste zwei Ebenen der Regelnummer. Der Context ordnet CIS-CAT über den Berichtsabschnitt zu; ein ganzer nicht katalogisierter Abschnitt kann daher mit einem Eintrag geschlossen werden.
| Regel-ID | Abschnitt | Titel |
|---|---|---|
| `1.1.1` | 1.1 | 1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)' |
| `1.1.2` | 1.1 | 1.1.2 Ensure 'Maximum password age' is set to '365 or fewer days, but not 0' |
| `1.1.3` | 1.1 | 1.1.3 Ensure 'Minimum password age' is set to '1 or more day(s)' |
| `1.1.7` | 1.1 | 1.1.7 Ensure 'Store passwords using reversible encryption' is set to 'Disabled' |
| `2.3.4.1` | 2.3 | 2.3.4.1 Ensure 'Devices: Prevent users from installing printer drivers' is set to 'Enabled' |
| `2.3.6.1` | 2.3 | 2.3.6.1 Ensure 'Domain member: Digitally encrypt or sign secure channel data (always)' is set to 'Enabled' |
| `2.3.6.2` | 2.3 | 2.3.6.2 Ensure 'Domain member: Digitally encrypt secure channel data (when possible)' is set to 'Enabled' |
| `2.3.6.3` | 2.3 | 2.3.6.3 Ensure 'Domain member: Digitally sign secure channel data (when possible)' is set to 'Enabled' |
| `2.3.6.4` | 2.3 | 2.3.6.4 Ensure 'Domain member: Disable machine account password changes' is set to 'Disabled' |
| `2.3.6.5` | 2.3 | 2.3.6.5 Ensure 'Domain member: Maximum machine account password age' is set to '30 or fewer days, but not 0' |
| `2.3.6.6` | 2.3 | 2.3.6.6 Ensure 'Domain member: Require strong (Windows 2000 or later) session key' is set to 'Enabled' |
| `2.3.8.2` | 2.3 | 2.3.8.2 Ensure 'Microsoft network client: Send unencrypted password to third-party SMB servers' is set to 'Disabled' |
| `2.3.9.1` | 2.3 | 2.3.9.1 Ensure 'Microsoft network server: Amount of idle time required before suspending session' is set to '15 or fewer minute(s)' |
| `2.3.9.3` | 2.3 | 2.3.9.3 Ensure 'Microsoft network server: Disconnect clients when logon hours expire' is set to 'Enabled' |
| `2.3.10.1` | 2.3 | 2.3.10.1 Ensure 'Network access: Allow anonymous SID/Name translation' is set to 'Disabled' |
| `2.3.10.5` | 2.3 | 2.3.10.5 Ensure 'Network access: Let Everyone permissions apply to anonymous users' is set to 'Disabled' |
| `2.3.10.8` | 2.3 | 2.3.10.8 Ensure 'Network access: Remotely accessible registry paths' is configured |
| `2.3.10.9` | 2.3 | 2.3.10.9 Ensure 'Network access: Remotely accessible registry paths and sub-paths' is configured |
| `2.3.10.10` | 2.3 | 2.3.10.10 Ensure 'Network access: Restrict anonymous access to Named Pipes and Shares' is set to 'Enabled' |
| `2.3.10.12` | 2.3 | 2.3.10.12 Ensure 'Network access: Shares that can be accessed anonymously' is set to 'None' |
| `2.3.10.13` | 2.3 | 2.3.10.13 Ensure 'Network access: Sharing and security model for local accounts' is set to 'Classic - local users authenticate as themselves' |
| `2.3.13.1` | 2.3 | 2.3.13.1 Ensure 'Shutdown: Allow system to be shut down without having to log on' is set to 'Disabled' |
| `2.3.15.1` | 2.3 | 2.3.15.1 Ensure 'System objects: Require case insensitivity for non-Windows subsystems' is set to 'Enabled' |
| `2.3.15.2` | 2.3 | 2.3.15.2 Ensure 'System objects: Strengthen default permissions of internal system objects (e.g. Symbolic Links)' is set to 'Enabled' |
| `2.3.17.4` | 2.3 | 2.3.17.4 Ensure 'User Account Control: Detect application installations and prompt for elevation' is set to 'Enabled' |
| `2.3.17.5` | 2.3 | 2.3.17.5 Ensure 'User Account Control: Only elevate UIAccess applications that are installed in secure locations' is set to 'Enabled' |
| `2.3.17.6` | 2.3 | 2.3.17.6 Ensure 'User Account Control: Run all administrators in Admin Approval Mode' is set to 'Enabled' |
| `2.3.17.7` | 2.3 | 2.3.17.7 Ensure 'User Account Control: Switch to the secure desktop when prompting for elevation' is set to 'Enabled' |
| `2.3.17.8` | 2.3 | 2.3.17.8 Ensure 'User Account Control: Virtualize file and registry write failures to per-user locations' is set to 'Enabled' |
| `19.7.5.1` | 19.7 | 19.7.5.1 Ensure 'Do not preserve zone information in file attachments' is set to 'Disabled' |
| `19.7.5.2` | 19.7 | 19.7.5.2 Ensure 'Notify antivirus programs when opening attachments' is set to 'Enabled' |
| `19.7.26.1` | 19.7 | 19.7.26.1 Ensure 'Prevent users from sharing files within their profile.' is set to 'Enabled' |
| `19.7.46.2.1` | 19.7 | 19.7.46.2.1 Ensure 'Prevent Codec Download' is set to 'Enabled' |
## GPO-Einstellungen (Verdict Passed)
| Einstellung | Scope | Technische Identität |
|---|---|---|
| allowdde | user | `gpo-policy-identity-fe0f949d14c555c06980c7f1be88ecab` |
| enableoleactions | user | `gpo-policy-identity-de40f1ecce2e45b1899716ec95948cb6` |
| EnableScriptBlockInvocationLogging | computer | `gpo-policy-identity-7b0456c1b66a87c2bb97a99315b119d4` |
| fAllowFullControl | computer | `gpo-policy-identity-6d0463b00d14f0674cebe9ed81bfca67` |
| fUseMailto | computer | `gpo-policy-identity-20a81de462534bd60111802c0ecd1ec6` |
| MaxTicketExpiry | computer | `gpo-policy-identity-8590d6843170a869ba261df8ce43b34e` |
| MaxTicketExpiryUnits | computer | `gpo-policy-identity-db2aa9231047c05e8914b11c1bbc1e18` |
| privilege rights · secreatepagefileprivilege | computer | `gpo-policy-identity-b6237595766e0910af2c9b5246f1c6ef` |
| privilege rights · seprofilesingleprocessprivilege | computer | `gpo-policy-identity-b79dd2a5cbf48fadac6a853bfa1588ce` |
| registry values · machine\system\currentcontrolset\control\lsa\nolmhash | computer | `gpo-policy-identity-8750b6186583a650aedbd8cde05b5dce` |
| registry values · machine\system\currentcontrolset\services\lanmanserver\parameters\enablesecuritysignature | computer | `gpo-policy-identity-07391ebdcd8b74f59c174e0dcf465774` |
| registry values · machine\system\currentcontrolset\services\lanmanserver\parameters\requiresecuritysignature | computer | `gpo-policy-identity-40df1cce2a18ab54de584d997a763dce` |
| registry values · machine\system\currentcontrolset\services\netlogon\parameters\requiresignorseal | computer | `gpo-policy-identity-68e6d42ea8fc7b9b8de723f2cba019ef` |
| registry values · machine\system\currentcontrolset\services\ntds\parameters\ldapserverintegrity | computer | `gpo-policy-identity-69964f975c2afa036c53821d833ac388` |
| Software\Policies\Microsoft\Edge\ExtensionInstallBlocklist | computer | `gpo-policy-identity-9c404b72d73c89d991f6bd879de714b2` |
| SOFTWARE\Policies\Microsoft\Windows\DeviceInstall\Restrictions\DenyDeviceClasses | computer | `gpo-policy-identity-9c23ac1bc5d4049c4460cfac648236af` |
| SOFTWARE\Policies\Microsoft\Windows\DeviceInstall\Restrictions\DenyDeviceIDs | computer | `gpo-policy-identity-71d55b57e12919c3eaf1ceb41f73f18d` |
| system access · lsaanonymousnamelookup | computer | `gpo-policy-identity-f9b99fd736dfaef934d63699413dc71f` |

26
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "assessor",
"version": "2.11.2",
"version": "2.16.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assessor",
"version": "2.11.2",
"version": "2.16.5",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",
@ -41,7 +41,7 @@
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
@ -57,7 +57,7 @@
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
@ -66,7 +66,7 @@
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
@ -951,7 +951,7 @@
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
@ -1571,7 +1571,7 @@
"react-refresh": "^0.18.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^20.19.0 || >=22.12.2"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
@ -1896,7 +1896,7 @@
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/debug": {
@ -2092,7 +2092,7 @@
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
@ -2137,7 +2137,7 @@
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": "^20.19.0 || ^22.13.5 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
@ -2839,7 +2839,7 @@
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^20.19.0 || >=22.12.2"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@ -2848,7 +2848,7 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@types/node": "^20.19.0 || >=22.12.2",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
@ -3033,7 +3033,7 @@
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^20.19.0 || ^22.12.2 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {

View File

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

View File

@ -14,6 +14,8 @@ import type {
} from '../src/persistence/assessor-repository';
import type { UnifiedResultQuery } from '../src/domain/unified-result';
import type { EnvironmentSnapshotQuery } from '../src/domain/environment-context';
import type { ClientInformationQuery } from '../src/domain/client-information';
import { clientInformationReadPortFrom } from '../src/persistence/client-information-read-port';
import type { Assessment, AssessmentProject, FindingReview, ImportFileRecord } from '../src/domain/canonical-model';
import type { LocalComparisonRule } from '../src/domain/comparison';
import { ImportError } from '../src/shared/errors';
@ -40,6 +42,8 @@ import { assembleConsistentReportInput, createReportRun, ReportStateChangedError
import { reportReadPortFrom, type ReportReadPort } from '../src/reporting/report-read-port';
import type { FileSystemReportArchive } from './reporting/report-archive';
import { ContextStateChangedError, createAssessmentContext } from '../src/assessment-context/context-run';
import { publishContextReport } from '../src/assessment-context/context-report-publication';
import { ReportContextRequiredError } from '../src/reporting/report-context';
import type { AssessmentContext } from '../src/assessment-context/context-engine';
import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis';
@ -363,6 +367,7 @@ function statusForError(error: unknown): { status: number; code: string; message
if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message };
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
if (error instanceof ReportStateChangedError) return { status: 409, code: 'report_state_changed', message: error.message };
if (error instanceof ReportContextRequiredError) return { status: 409, code: 'report_context_required', message: error.message };
if (error instanceof ContextStateChangedError) return { status: 409, code: 'context_state_changed', 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 };
@ -395,6 +400,8 @@ async function serveStatic(request: IncomingMessage, response: ServerResponse, s
export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server {
const repository = options.repository;
const clientInformation = clientInformationReadPortFrom(repository,
() => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision));
const reportPort = options.reportPort === undefined
? reportReadPortFrom(repository, async () => repository.revision) : options.reportPort;
const requireReportPort = async (projectId: string): Promise<ReportReadPort> => {
@ -927,6 +934,29 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
scanRuns: await repository.listProjectScanRuns(segments[1])
}, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'client-information' && segments.length === 3 && method === 'GET') {
const params = url.searchParams;
const allowed = new Set(['assessmentId', 'scanRunIds', 'includeSuperseded', 'areas', 'properties', 'subjectTypes', 'subjectIds', 'toolIds', 'states', 'search', 'offset', 'limit', 'sectionOffset', 'sectionLimit']);
if ([...params.keys()].some(key => !allowed.has(key))) throw new RepositoryValidationError('Unknown Client Information query parameter.');
const scalarKeys = ['assessmentId', 'includeSuperseded', 'search', 'offset', 'limit', 'sectionOffset', 'sectionLimit'];
if (scalarKeys.some(key => params.getAll(key).length > 1)) throw new RepositoryValidationError('Repeated scalar Client Information parameter.');
const history = params.get('includeSuperseded');
if (history !== null && history !== 'true' && history !== 'false') throw new RepositoryValidationError('Invalid history selector.');
const numeric = (key: string) => {
const value = params.get(key);
if (value !== null && !/^\d+$/.test(value)) throw new RepositoryValidationError('Invalid Client Information pagination.');
return value === null ? undefined : Number(value);
};
const query: ClientInformationQuery = {
projectId: segments[1], assessmentId: params.get('assessmentId') ?? undefined,
scanRunIds: params.getAll('scanRunIds'), includeSuperseded: history === 'true',
areas: params.getAll('areas') as ClientInformationQuery['areas'], properties: params.getAll('properties'),
subjectTypes: params.getAll('subjectTypes'), subjectIds: params.getAll('subjectIds'), toolIds: params.getAll('toolIds'),
states: params.getAll('states') as ClientInformationQuery['states'], search: params.get('search') ?? undefined,
offset: numeric('offset'), limit: numeric('limit'), sectionOffset: numeric('sectionOffset'), sectionLimit: numeric('sectionLimit')
};
status = 200; sendJson(response, status, await clientInformation.queryClientInformation(query), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'environment-snapshot' && segments.length === 3 && method === 'GET') {
const snapshotQuery: EnvironmentSnapshotQuery = {
projectId: segments[1],
@ -991,10 +1021,25 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
? value as string[] : undefined;
if (!options.reportArchive) throw new HttpError(503, 'report_archive_unavailable', 'Das Report-Archiv ist nicht konfiguriert.');
const startedAt = now();
const run = await createReportRun(await requireReportPort(segments[1]), {
const port = await requireReportPort(segments[1]);
const run = await createReportRun(port, {
projectId: segments[1], generatedAt: startedAt.toISOString(),
assessmentIds: ids(body.assessmentIds), scanRunIds: ids(body.scanRunIds)
});
}, body.kind === 'management' ? {
requireContext: true,
readContext: async scope => {
const assessmentIds = [...new Set(scope.assessmentIds)].sort();
const scanRunIds = [...new Set(scope.scanRunIds ?? [])].sort();
const key = JSON.stringify([await port.currentRevision(), scope.projectId, assessmentIds, scanRunIds]);
let context = assessmentContextCache.get(key);
if (!context) {
context = await createAssessmentContext(port, { ...scope, assessmentIds, scanRunIds });
assessmentContextCache.set(JSON.stringify([context.repositoryRevision, scope.projectId, assessmentIds, scanRunIds]), context);
while (assessmentContextCache.size > 8) assessmentContextCache.delete(assessmentContextCache.keys().next().value!);
}
return publishContextReport(context);
}
} : { requireContext: false });
const manifest = await options.reportArchive.write(run, startedAt);
const result: ReportRunResponse = {
runId: manifest.runId, generatedAt: run.projection.generatedAt, repositoryRevision: run.revision, kind: body.kind,

View File

@ -69,6 +69,7 @@ import { processingScopeIssues } from '../../src/domain/processing-compatibility
import { compareReimportSourceHashes } from '../../src/domain/reimport-source';
import { planReimportReviewTransfer, type ReimportReviewTransferPlan, type ReimportReviewTransferSummary } from '../../src/domain/reimport-review-transfer';
import { canonicalJson } from '../../src/shared/canonical-json';
import { RevisionReadCache } from './revision-read-cache';
import { validateLocalComparisonRules } from '../../src/comparison/local-mapping-rules';
import type {
AssessorRepository,
@ -391,6 +392,7 @@ function dbStructureLayer(tableName: string): DbStructureLayerId {
export class PostgresAssessorRepository implements AssessorRepository {
private cachedRevision: number;
private readonly activeImportSessions = new Set<string>();
private readonly unifiedReadCache = new RevisionReadCache<UnifiedResultProjection[]>();
private constructor(private readonly pool: Pool, initialRevision: number) {
this.cachedRevision = initialRevision;
@ -424,6 +426,12 @@ export class PostgresAssessorRepository implements AssessorRepository {
return this.cachedRevision;
}
async getCommittedRevision(): Promise<number> {
const result = await this.pool.query<RevisionRow>('SELECT revision FROM assessor_repository_state WHERE singleton = true');
if (!result.rows[0]) throw new Error('Repository revision is unavailable.');
return Number(result.rows[0].revision);
}
private async mutate<T>(
work: (client: PoolClient) => Promise<T>,
expectedRevision?: number,
@ -1251,6 +1259,11 @@ export class PostgresAssessorRepository implements AssessorRepository {
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
// The Result DB is served only from the stored, disclosure-checked rows (see materializeResultDb).
if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);
const { offset: _offset, limit: _limit, ...scope } = structuredClone(query);
return this.unifiedReadCache.read(canonicalJson(scope), () => this.databaseRevision(), () => this.loadInternalUnifiedResults(scope));
}
private async loadInternalUnifiedResults(query: UnifiedResultQuery): Promise<UnifiedResultProjection[]> {
const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([
this.unifiedScannerResults(query),
this.unifiedEnvironmentResults(query),
@ -1400,7 +1413,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
const result = queryUnifiedResultItems(await this.allUnifiedResults(query), query);
return query.audience === 'internal-analysis'
? result
? structuredClone(result)
: { ...result, items: result.items.map(removeResultDbScopeIdentifiers) };
}
@ -1417,7 +1430,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
const results = await this.allUnifiedResults(query);
return listUnifiedResultCategories(filterUnifiedResults(results, query));
return structuredClone(listUnifiedResultCategories(filterUnifiedResults(results, query)));
}
async adoptScanRunReplacement(projectId: string, replacedScanRunId: string, replacementScanRunId: string, supersededAt: string): Promise<ReimportReviewTransferSummary> {
@ -1493,6 +1506,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
if (!context.rows[0]?.assessment_exists) throw new RepositoryValidationError('Environment Snapshot verweist auf ein Assessment außerhalb des Projekts.');
const values: unknown[] = [query.projectId];
const conditions = ['a.project_id = $1'];
if (!query.includeSuperseded) conditions.push("r.data->>'supersededByScanRunId' IS NULL");
if (query.assessmentId) {
values.push(query.assessmentId);
conditions.push(`a.id = $${values.length}`);

View File

@ -0,0 +1,50 @@
/** Process-local read reuse. Every access checks the live database revision, including cache hits. */
export class RevisionReadCache<T> {
private readonly entries = new Map<string, { value: Promise<T>; bytes: number }>();
private revision: number | undefined;
constructor(private readonly maxEntries = 8, private readonly maxBytes = 32 * 1024 * 1024) {}
async read(key: string, currentRevision: () => Promise<number>, load: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const before = await currentRevision();
if (!Number.isSafeInteger(before) || before < 0) throw new Error('Cannot verify the database revision.');
if (this.revision !== before) {
this.entries.clear();
this.revision = before;
}
let entry = this.entries.get(key);
if (!entry) {
entry = { value: Promise.resolve().then(load), bytes: 0 };
this.entries.set(key, entry);
while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value!);
}
// Refresh insertion order for bounded least-recently-used retention.
if (this.entries.get(key) === entry) {
this.entries.delete(key);
this.entries.set(key, entry);
}
try {
const value = await entry.value;
if (await currentRevision() !== before) {
if (this.entries.get(key) === entry) this.entries.delete(key);
continue;
}
if (this.entries.get(key) === entry && entry.bytes === 0) {
entry.bytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
let retainedBytes = [...this.entries.values()].reduce((sum, item) => sum + item.bytes, 0);
while (retainedBytes > this.maxBytes && this.entries.size) {
const oldest = this.entries.keys().next().value!;
retainedBytes -= this.entries.get(oldest)!.bytes;
this.entries.delete(oldest);
}
}
return value;
} catch (error) {
if (this.entries.get(key) === entry) this.entries.delete(key);
throw error;
}
}
throw new Error('ASSESSOR data changed repeatedly while results were read. Please retry.');
}
}

View File

@ -55,9 +55,14 @@ export class ReferenceRecognitionService {
for (const scan of model.scanRuns.filter(item => !item.supersededByScanRunId)) {
const latestTimestamp = Math.max(0, ...[...existing.values()].filter(run => run.scanRunId === scan.id).map(run => Date.parse(run.createdAt)));
const run = deriveReferenceRun(model, scan.id, catalogs, new Date(Math.max(Date.now(), latestTimestamp + 1)).toISOString());
const latest = latestReferenceRuns([...existing.values()]).find(item => item.scanRunId === scan.id);
references += run.results.reduce((sum, item) => sum + item.references.length, 0);
if (existing.has(run.id)) { unchangedScans++; continue; }
if (latest && latest.inputSha256 === run.inputSha256 && latest.recognizerVersion === run.recognizerVersion
&& [...latest.catalogHashes].sort().join(':') === run.catalogHashes.join(':')) { unchangedScans++; continue; }
// Returning to a previously used catalog needs a new immutable run to become latest.
if (existing.has(run.id)) run.id = 'references-' + referenceHash([run.id, run.createdAt]);
await this.repository.saveReferenceRun(run, catalogs);
existing.set(run.id, run);
processedScans++;
}
}

View File

@ -40,7 +40,8 @@ export function buildRecord(input: {
}));
const filled = fields.filter(field => field.value);
// ID-Kopfzeilen (z. B. ShortName, Rule ID) duerfen nicht als Titelfeld verwendet werden, da z. B. ShortName "name" als Substring enthaelt.
const isIdHeader = (label: string) => /^(?:short\s*name|shortname|rule\s*id|control\s*id|test\s*id|id|key|code)$/i.test(label.trim());
// The derived `Benchmark section` path names a report section, never the check itself.
const isIdHeader = (label: string) => /^(?:short\s*name|shortname|rule\s*id|control\s*id|test\s*id|id|key|code|benchmark\s*section)$/i.test(label.trim());
// Provenienz- und Metadaten-Felder (wie "Quelle: Datei") duerfen nie als Fallback-Titel gewählt werden.
const nonProvenanceFilled = filled.filter(field => !/^(?:quelle:|source:|datei|file|sheet|blatt|scan)/i.test(field.label.trim()));
// Der Titel bleibt bewusst einzeilig: er steht in Kachel, Kartenliste und

View File

@ -12,6 +12,7 @@ import type {
import type { ProjectDataExport } from '../persistence/assessor-repository';
import type { DatahubTimeslotResultMetrics } from '../features/project-import/datahub-scan-analysis';
import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/environment-context';
import type { ClientInformationQuery, ClientInformationResult } from '../domain/client-information';
import type { ImportedDataset } from '../domain/dataset';
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
@ -119,7 +120,7 @@ export class AssessorApiClient {
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const descriptor = this.describeActivity(path, init.method);
const run = () => this.performRequest<T>(path, init);
return descriptor ? trackActivity(descriptor.area, descriptor.label, run) : run();
return descriptor ? trackActivity(descriptor.area, descriptor.label, run, `${this.baseUrl} ${init.method ?? 'GET'} ${path}`) : run();
}
private async performRequest<T>(path: string, init: RequestInit): Promise<T> {
@ -280,6 +281,16 @@ export class AssessorApiClient {
return this.request(`/projects/${encodeURIComponent(query.projectId)}/environment-snapshot${suffix}`);
}
queryClientInformation(query: ClientInformationQuery): Promise<ClientInformationResult> {
const parameters = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (key === 'projectId' || value === undefined) continue;
if (Array.isArray(value)) value.forEach(item => parameters.append(key, String(item)));
else parameters.set(key, String(value));
}
return this.request(`/projects/${encodeURIComponent(query.projectId)}/client-information${parameters.size ? `?${parameters}` : ''}`);
}
deleteAssessment(projectId: string, assessmentId: string): Promise<{ deleted: boolean; repositoryRevision: number }> {
return this.request(`/projects/${encodeURIComponent(projectId)}/assessments/${encodeURIComponent(assessmentId)}`, {
method: 'DELETE', body: JSON.stringify({ projectId, assessmentId, cascade: true })

View File

@ -113,14 +113,14 @@ export function AppProvider({ children }: PropsWithChildren) {
const engines = useEnginePreferences();
useEffect(() => {
if (engines.context) return;
setState(current => {
if (!current.facetFilters['context:cluster']) return current;
const facetFilters = { ...current.facetFilters };
delete facetFilters['context:cluster'];
if (!engines.context) delete facetFilters['context:cluster'];
if (!engines.context || !engines.report) delete facetFilters['report:business-impact'];
if (Object.keys(facetFilters).length === Object.keys(current.facetFilters).length) return current;
return { ...current, facetFilters };
});
}, [engines.context, state.facetFilters]);
}, [engines.context, engines.report, state.facetFilters]);
useEffect(() => {
let active = true;

View File

@ -4,7 +4,7 @@
* `not semantically mapped` and is never guessed. Conditions are defined by explicit value sets;
* every other value is `unknown`. Nothing here is a compliance verdict or an effective host state.
*/
export const ASSESSMENT_CONTEXT_CATALOG = { id: 'assessment-context-catalog', version: '1.9.0' } as const;
export const ASSESSMENT_CONTEXT_CATALOG = { id: 'assessment-context-catalog', version: '1.10.0' } as const;
export interface ContextSource {
title: string;
@ -123,6 +123,11 @@ export interface AssessmentContextCatalog {
gpoVerdictRules: ContextGpoVerdictRule[];
/** Condition for each source's deviations that no rule or section assigns yet; they stay visible. */
uncataloguedDeviationConditions: Record<ContextScannerRule['tool'] | 'gpo', string>;
/**
* One finding, one cluster (user decision 2026-10-04): a condition several clusters list counts only for the
* cluster whose attack mechanism it names most specifically. Every shared condition must declare it here.
*/
primaryClusters?: Record<string, string>;
}
const SECURITY_POLICY_DOCS = 'https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/';
@ -371,6 +376,9 @@ export const RELEASED_ASSESSMENT_CONTEXT_CATALOG: AssessmentContextCatalog = {
{ id: 'ad-rc4-des-on-dcs', label: { de: 'Domain Controller unterstützen RC4 oder DES', en: 'Domain controllers support RC4 or DES' } },
{ id: 'ad-privileged-password-never-expires', label: { de: 'Privilegierte Konten haben ein nie ablaufendes Passwort', en: 'Privileged accounts have a password that never expires' } },
{ id: 'ad-password-never-expires', label: { de: 'Konten haben das Flag Password Never Expires', en: 'Accounts have the Password Never Expires flag' } },
{ id: 'ad-smartcard-password-rotation-disabled', label: {
de: 'Kennwortrotation fuer Smartcard-only-Konten ist deaktiviert', en: 'Smart card-only account password rotation is disabled'
} },
{ id: 'ad-shadow-credentials-privileged', label: { de: 'Privilegierte Objekte tragen Shadow Credentials', en: 'Privileged objects carry shadow credentials' } },
{ id: 'mdns-not-disabled', label: { de: 'Multicast DNS (mDNS) ist nicht abgeschaltet', en: 'Multicast DNS (mDNS) is not turned off' } },
{ id: 'netbios-not-disabled', label: { de: 'NetBIOS-Namensauflösung ist nicht abgeschaltet', en: 'NetBIOS name resolution is not turned off' } },
@ -740,6 +748,14 @@ export const RELEASED_ASSESSMENT_CONTEXT_CATALOG: AssessmentContextCatalog = {
statement: { de: 'Nie ablaufende Passwörter begünstigen, dass geknackte Passwörter dauerhaft gültig bleiben.', en: 'Never-expiring passwords favour cracked passwords staying valid indefinitely.' },
sources: [T1558_003]
},
{
conditionId: 'ad-smartcard-password-rotation-disabled', role: 'prerequisite', techniqueIds: [],
statement: {
de: 'Deaktivierte Kennwortrotation für Smartcard-only-Konten begünstigt die dauerhafte Nutzung übernommener Zugangsdaten und seitliche Bewegung.',
en: 'Disabled password rotation for smart card-only accounts favours lasting use of compromised credentials and lateral movement.'
},
sources: [PURPLE_KNIGHT]
},
{
conditionId: 'ad-shadow-credentials-privileged', role: 'prerequisite', techniqueIds: [],
statement: { de: 'Shadow Credentials an privilegierten Objekten begünstigen die Anmeldung als dieses Objekt ohne sein Passwort.', en: 'Shadow credentials on privileged objects favour authenticating as that object without its password.' },
@ -1654,7 +1670,7 @@ export const RELEASED_ASSESSMENT_CONTEXT_CATALOG: AssessmentContextCatalog = {
en: 'Several observed findings favour the same path: spreading through the network over remote services with valid credentials. This is not proof that an attack is possible or took place.'
},
conditionIds: ['rdp-without-nla', 'winrm-basic-auth', 'winrm-unencrypted', 'winrm-remote-management-allowed', 'smbv1-enabled', 'host-firewall-off', 'host-firewall-inbound-allowed', 'network-logon-rights-broad',
'laps-not-deployed', 'rdp-sessions-not-timed-out', 'terminal-services-gpo-incomplete', 'rdp-connection-security-weak',
'laps-not-deployed', 'ad-smartcard-password-rotation-disabled', 'rdp-sessions-not-timed-out', 'terminal-services-gpo-incomplete', 'rdp-connection-security-weak',
'cis-remote-desktop-unmet', 'cis-remote-management-unmet', 'cis-firewall-profile-unmet', 'cis-network-stack-unmet', 'gpo-network-logon-right-deviates', 'gpo-firewall-profile-deviates'],
minimumSatisfied: 2,
sources: [T1021]
@ -1928,6 +1944,11 @@ export const RELEASED_ASSESSMENT_CONTEXT_CATALOG: AssessmentContextCatalog = {
ruleText: 'RC4 or DES encryption type are supported by Domain Controllers', sources: [] },
{ tool: 'purple-knight', ruleId: 'SI000067', conditionId: 'ad-privileged-password-never-expires', polarity: 'fail-means-met',
ruleText: 'Privileged accounts with a password that never expires', sources: [] },
// Source: user-supplied Purple Knight SI000147 report (2026-10-02), not a tactic-only assignment.
// The report states disabled msDS-ExpirePasswordsOnSmartCardOnlyAccounts permits indefinite credentials
// and can favour persistent access and lateral movement. No effective compromise is asserted.
{ tool: 'purple-knight', ruleId: 'SI000147', conditionId: 'ad-smartcard-password-rotation-disabled', polarity: 'fail-means-met',
ruleText: 'Smart card password rotation disabled', sources: [PURPLE_KNIGHT] },
{ tool: 'purple-knight', ruleId: 'SI000029', conditionId: 'ad-password-never-expires', polarity: 'fail-means-met',
ruleText: 'Users with Password Never Expires flag set', sources: [] },
{ tool: 'purple-knight', ruleId: 'SI000351', conditionId: 'ad-shadow-credentials-privileged', polarity: 'fail-means-met',
@ -2532,6 +2553,16 @@ export const RELEASED_ASSESSMENT_CONTEXT_CATALOG: AssessmentContextCatalog = {
{ target: { mechanism: 'security-template', scope: 'computer', section: "System Access", key: "ForceLogoffWhenHourExpire" }, conditionId: 'gpo-logon-hours-enforcement-deviates' },
{ target: { mechanism: 'registry-key', scope: 'user', key: "Software\\Policies\\Microsoft\\Office\\16.0\\Common", valueName: "fbaenabledhosts" }, conditionId: 'gpo-office-forms-auth-hosts-deviate' }
],
primaryClusters: {
'adcs-web-enrollment-esc8': 'adcs-escalation',
'cis-print-hardening-unmet': 'host-credential-privilege-exposure',
'ad-admins-delegatable': 'kerberos-delegation-abuse',
'ad-privileged-spn': 'kerberoasting-exposure',
'ad-service-accounts-no-strong-policy': 'kerberoasting-exposure',
'ad-privileged-password-never-expires': 'password-guessing-exposure',
'ad-password-never-expires': 'password-guessing-exposure',
'domain-admins-password-never-expires': 'password-guessing-exposure'
},
uncataloguedDeviationConditions: { 'gpo': 'uncatalogued-deviations-gpo', 'pingcastle': 'uncatalogued-deviations-pingcastle', 'purple-knight': 'uncatalogued-deviations-purple-knight', 'cis-cat': 'uncatalogued-deviations-cis-cat', 'maester': 'uncatalogued-deviations-maester' }
};
@ -2605,5 +2636,17 @@ export function assessmentContextCatalogErrors(catalog: AssessmentContextCatalog
}
sourceOk(cluster.sources, cluster.id);
}
// One finding, one cluster (user decision 2026-10-04): every shared condition declares exactly one of its clusters.
for (const conditionId of conditionIds) {
const owners = catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
const primary = catalog.primaryClusters?.[conditionId];
if (owners.length > 1 && !primary) errors.push(`${conditionId}: shared by ${owners.join(', ')} without a primary cluster.`);
if (primary && !owners.includes(primary)) errors.push(`${conditionId}: primary cluster ${primary} does not list the condition.`);
}
return errors;
}
/** The single cluster a catalogued condition counts for (user decision 2026-10-04). */
export function primaryClusterOfCondition(catalog: AssessmentContextCatalog, conditionId: string): string | undefined {
return catalog.primaryClusters?.[conditionId] ?? catalog.clusters.find(cluster => cluster.conditionIds.includes(conditionId))?.id;
}

View File

@ -0,0 +1,164 @@
/**
* User-approved category baseline for the Context heatmap, researched 2026-10-02.
* This is ASSESSOR's thematic assessment, not a severity issued by the cited publishers,
* a CVSS score, a finding severity, or a judgement about exploitability in a client.
* Sources and explanations are bundled offline; no network lookup occurs at runtime.
*/
export const CONTEXT_SEVERITY_CATALOG = {
id: 'context-cluster-severity', version: '1.0.0', reviewedAt: '2026-10-02'
} as const;
export type ContextBaseSeverity = 'critical' | 'high' | 'medium' | 'undetermined';
export interface ContextSeverityReference {
clusterId: string;
severity: ContextBaseSeverity;
rationale: string;
qualification: string;
sources: ReadonlyArray<{ title: string; publisher: string; url: string }>;
}
export const contextSeverityLabels: Record<ContextBaseSeverity, string> = {
critical: 'Critical', high: 'High', medium: 'Medium', undetermined: 'Undetermined'
};
export const contextClusterSeverities: readonly ContextSeverityReference[] = [
{
clusterId: 'credential-relay-exposure', severity: 'high',
rationale: 'Relayed authentication can enable unauthorized access and privilege escalation.',
qualification: 'Impact depends on the target and account privileges. A missing protection alone does not establish a working relay path.',
sources: [{ publisher: 'Microsoft', title: 'SMB security hardening', url: 'https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-security-hardening' }]
},
{
clusterId: 'cloud-privileged-access-exposure', severity: 'critical',
rationale: 'Compromise of cloud administrative identities can expose central identity, authorization and security controls.',
qualification: 'The actual impact depends on the roles and scopes involved; a control gap is not proof of administrative compromise.',
sources: [{ publisher: 'Microsoft', title: 'Privileged access strategy', url: 'https://learn.microsoft.com/security/privileged-access-workstations/privileged-access-strategy' }]
},
{
clusterId: 'cloud-sign-in-protection-gaps', severity: 'high',
rationale: 'Weak sign-in protections can favour account takeover through stolen credentials or legacy authentication.',
qualification: 'Effective authentication policies, exclusions and the affected identities determine the client-specific impact.',
sources: [{ publisher: 'Microsoft', title: 'Protect identities with Zero Trust', url: 'https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities' }]
},
{
clusterId: 'ad-privileged-account-exposure', severity: 'critical',
rationale: 'Exposed privileged AD identities can enable control over central directory services and connected systems.',
qualification: 'Account privileges and reachable administrative paths matter; the category does not assert a completed takeover.',
sources: [{ publisher: 'Microsoft', title: 'Securing privileged access', url: 'https://learn.microsoft.com/windows-server/identity/securing-privileged-access/securing-privileged-access' }]
},
{
clusterId: 'kerberos-delegation-abuse', severity: 'critical',
rationale: 'Abusable delegation can enable impersonation of privileged identities and compromise of domain services.',
qualification: 'Delegation scope and prerequisites determine impact. A narrowly scoped service configuration can have lower impact.',
sources: [{ publisher: 'Microsoft', title: 'Identity security posture assessments for accounts', url: 'https://learn.microsoft.com/en-us/defender-for-identity/security-posture-assessments/accounts' }]
},
{
clusterId: 'adcs-escalation', severity: 'critical',
rationale: 'Unsafe certificate enrollment configurations can allow impersonation of privileged domain identities.',
qualification: 'Enrollment permissions, template settings and approval requirements must permit the specific escalation path.',
sources: [{ publisher: 'Microsoft', title: 'Insecure AD CS certificate enrollment', url: 'https://learn.microsoft.com/en-au/defender-for-identity/security-assessment-insecure-adcs-certificate-enrollment' }]
},
{
clusterId: 'kerberoasting-exposure', severity: 'high',
rationale: 'Service tickets can support offline password cracking and compromise of service accounts.',
qualification: 'Password strength and service-account privileges determine whether this exposure leads to significant compromise.',
sources: [{ publisher: 'MITRE', title: 'ATT&CK: Kerberoasting', url: 'https://attack.mitre.org/techniques/T1558/003/' }]
},
{
clusterId: 'lateral-movement-exposure', severity: 'high',
rationale: 'Weak remote-access controls can favour movement from one compromised system to other systems.',
qualification: 'An available administrative service alone is not a vulnerability; authentication, permissions and reachability matter.',
sources: [{ publisher: 'MITRE', title: 'ATT&CK: Remote Services', url: 'https://attack.mitre.org/techniques/T1021/' }]
},
{
clusterId: 'host-credential-privilege-exposure', severity: 'high',
rationale: 'Exposed credentials and local privileges can enable credential theft, elevation and further access.',
qualification: 'Impact increases when privileged credentials are available on the affected host.',
sources: [{ publisher: 'Microsoft', title: 'How Credential Guard works', url: 'https://learn.microsoft.com/windows/security/identity-protection/credential-guard/how-it-works/' }]
},
{
clusterId: 'detection-blind-spots', severity: 'high',
rationale: 'Missing security telemetry can delay detection, containment and investigation of an intrusion.',
qualification: 'This is a defensive visibility gap; it does not itself demonstrate a means of initial access.',
sources: [{ publisher: 'ASD ACSC', title: 'Best practices for event logging and threat detection', url: 'https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/best-practices-for-event-logging-and-threat-detection' }]
},
{
clusterId: 'password-guessing-exposure', severity: 'high',
rationale: 'Weak password and authentication protections can favour unauthorized access through password guessing.',
qualification: 'Reachability, MFA, rate limits and account privileges affect the practical impact.',
sources: [{ publisher: 'MITRE', title: 'ATT&CK: Password Guessing', url: 'https://attack.mitre.org/techniques/T1110/001/' }]
},
{
clusterId: 'malicious-code-execution-exposure', severity: 'high',
rationale: 'Weak execution protections can favour malicious scripts or applications, including ransomware and data theft.',
qualification: 'A missing execution control is not evidence that malicious code has run.',
sources: [{ publisher: 'Microsoft', title: 'Virus and threat protection', url: 'https://learn.microsoft.com/en-us/windows/security/book/operating-system-security-virus-and-threat-protection' }]
},
{
clusterId: 'ad-persistence-indicators', severity: 'high',
rationale: 'Altered protected-account permissions or related directory conditions can favour persistent privileged access.',
qualification: 'The cluster is heterogeneous. A stale adminCount value alone does not prove persistence or compromise.',
sources: [{ publisher: 'Microsoft', title: 'Reducing the Active Directory attack surface', url: 'https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/reducing-the-active-directory-attack-surface' }]
},
{
clusterId: 'ad-reconnaissance-exposure', severity: 'medium',
rationale: 'Directory enumeration can reveal accounts and relationships useful for subsequent attacks.',
qualification: 'Reconnaissance supports follow-on activity but does not by itself grant control of the directory.',
sources: [{ publisher: 'MITRE', title: 'ATT&CK: Domain Account Discovery', url: 'https://attack.mitre.org/techniques/T1087/002/' }]
},
{
clusterId: 'ad-recovery-gaps', severity: 'high',
rationale: 'Insufficient directory recovery preparations can prolong outages and prevent reliable recovery after compromise.',
qualification: 'Missing usable forest backups can have greater impact; actual recoverability requires recovery evidence.',
sources: [{ publisher: 'Microsoft', title: 'AD forest recovery: determine how to recover', url: 'https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/forest-recovery-guide/ad-forest-recovery-determine-how-to-recover' }]
},
{
clusterId: 'cloud-app-consent-exposure', severity: 'high',
rationale: 'Malicious application consent can grant persistent access to cloud data and services.',
qualification: 'Granted permissions determine impact. An expiring application secret is not equivalent to a malicious consent grant.',
sources: [{ publisher: 'Microsoft', title: 'Detect and remediate illicit consent grants', url: 'https://learn.microsoft.com/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants' }]
},
{
clusterId: 'cloud-external-access-exposure', severity: 'medium',
rationale: 'Broad guest and external-sharing permissions can expose information to unintended recipients.',
qualification: 'External collaboration can be legitimate. Sensitive data or excessive permissions can make a specific exposure more severe.',
sources: [{ publisher: 'Microsoft', title: 'External sharing overview', url: 'https://learn.microsoft.com/en-us/sharepoint/external-sharing-overview' }]
},
{
clusterId: 'data-sharing-exposure', severity: 'medium',
rationale: 'Data sharing, synchronization and diagnostic-data settings can affect confidentiality and privacy.',
qualification: 'These settings do not by themselves establish exfiltration; the data, destinations and approved purpose matter.',
sources: [{ publisher: 'Microsoft', title: 'Configure Windows diagnostic data', url: 'https://learn.microsoft.com/en-us/windows/privacy/configure-windows-diagnostic-data-in-your-organization' }]
},
{
clusterId: 'physical-local-access-exposure', severity: 'medium',
rationale: 'Weak local-session and physical-access controls can favour access by someone with physical proximity.',
qualification: 'Physical access, unattended sessions and the privileges of the available account determine practical impact.',
sources: [
{ publisher: 'Microsoft', title: 'Interactive logon: machine inactivity limit', url: 'https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/interactive-logon-machine-inactivity-limit' },
{ publisher: 'MITRE', title: 'ATT&CK: Hardware Additions', url: 'https://attack.mitre.org/techniques/T1200/' }
]
},
{
clusterId: 'patch-configuration-drift', severity: 'high',
rationale: 'Weak patch and configuration maintenance can leave known vulnerabilities and protective configuration gaps unresolved.',
qualification: 'This cluster also covers policy processing and time synchronization. It does not prove that a specific critical patch is missing.',
sources: [{ publisher: 'NIST', title: 'SP 800-40 Rev. 4: Enterprise Patch Management Planning', url: 'https://csrc.nist.gov/pubs/sp/800/40/r4/final' }]
},
{
clusterId: 'email-spoofing-phishing-exposure', severity: 'high',
rationale: 'Weak email authentication and forwarding controls can favour impersonation, phishing and disclosure of messages.',
qualification: 'A control gap is not proof of a successful phishing attack or unauthorized forwarding.',
sources: [
{ publisher: 'Microsoft', title: 'Email authentication', url: 'https://learn.microsoft.com/en-us/defender-office-365/email-authentication-about' },
{ publisher: 'Microsoft', title: 'Control automatic external email forwarding', url: 'https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-policies-external-email-forwarding' }
]
},
{
clusterId: 'other-deviations', severity: 'undetermined',
rationale: 'Uncatalogued deviations have no common established mechanism from which to derive a category severity.',
qualification: 'Undetermined does not mean low severity or absence of a problem. Review the underlying findings.',
sources: []
}
];

View File

@ -5,11 +5,12 @@ import type { ReferenceRecognitionRun } from '../domain/reference-recognition';
import type { ScannerReportSection } from '../domain/environment-context';
import { cisBenchmarkSectionTitles } from '../profiles/cis-cat/cis-benchmark-section-titles';
import {
assessmentContextCatalogErrors, type AssessmentContextCatalog, type ContextCondition, type ContextRelationship,
assessmentContextCatalogErrors, primaryClusterOfCondition, type AssessmentContextCatalog, type ContextCondition, type ContextRelationship,
type ContextGpoVerdictTarget, type ContextScannerRule, type ContextSemanticDefinition, type ContextSettingTarget, type ContextSource, type ContextText
} from './context-catalog';
import { CONTEXT_RECOGNITION_TERMS, CONTEXT_RECOGNITION_VOCABULARY, type RecognitionTerm } from './context-recognition';
import { assignRemainingDeviations, type ContextAssignmentMethod } from './context-assignment';
import { contextClusterSeverities } from './context-cluster-severity';
/**
* Context engine (Phase 13E): a derived, switchable second reading of stored ASSESSOR results.
@ -17,7 +18,7 @@ import { assignRemainingDeviations, type ContextAssignmentMethod } from './conte
* GPO backups contribute what they declare; scanners contribute what they observed. Both stay
* separately attributed evidence of the same catalogued conditions.
*/
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.10.0' } as const;
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.11.0' } as const;
export type DeclaredValue =
| { state: 'configured'; value: number }
@ -123,7 +124,10 @@ export interface ContextSettingAttachment {
factIds: string[];
evaluationIds: string[];
conditionIds: string[];
/** Exactly one primary cluster per deviation (user decision 2026-10-04); empty when no cluster applies. */
clusterIds: string[];
/** Further clusters the evidence also names; traceable, but never counted (user decision 2026-10-04). */
secondaryClusterIds?: string[];
/** Confidence of each cluster that is not catalogued (user decision 2026-10-01); absent means catalogued. */
clusterConfidence?: Record<string, 'recognized' | 'estimated'>;
}
@ -136,7 +140,10 @@ export interface ContextFindingAttachment {
sourceRuleId: string;
observationIds: string[];
conditionIds: string[];
/** Exactly one primary cluster per finding (user decision 2026-10-04); empty when no cluster applies. */
clusterIds: string[];
/** Further clusters the evidence also names; traceable, but never counted (user decision 2026-10-04). */
secondaryClusterIds?: string[];
/** ATT&CK and D3FEND IDs the scanner itself declares for this finding (user decisions 2026-09-30). */
declaredReferenceIds: string[];
/** CIS benchmark sections containing this rule in its own scan report, top level first. */
@ -818,6 +825,40 @@ export function buildAssessmentContext(input: AssessmentContextInput): Assessmen
entry.clusterConfidence = { ...(entry.clusterConfidence ?? {}), [item.clusterId]: item.confidence };
}
// One finding, one cluster (user decision 2026-10-04): the strongest evidence decides, the rest stays traceable.
const support = new Map<string, Map<string, number>>();
const supports = (key: string, clusterIds: string[]) => {
const counts = support.get(key) ?? new Map<string, number>();
for (const clusterId of clusterIds) counts.set(clusterId, (counts.get(clusterId) ?? 0) + 1);
support.set(key, counts);
};
// A catalogued condition several clusters list supports only its declared primary cluster.
const primaryOf = (conditionId: string) => { const id = primaryClusterOfCondition(input.catalog, conditionId); return id ? [id] : []; };
for (const item of observations) supports(`finding:${item.occurrenceId}`, primaryOf(item.conditionId));
for (const item of techniqueAssignments) supports(`finding:${item.occurrenceId}`, [item.clusterId]);
for (const fact of facts) supports(`setting:${fact.scope}\u0000${fact.identityKey}`, semanticById.get(fact.semanticId)!.conditions.flatMap(condition => primaryOf(condition.id)));
for (const item of evaluations.filter(evaluation => evaluation.matchedBy !== 'assigned-by-recognition')) supports(`setting:${item.scope}\u0000${item.identityKey}`, primaryOf(item.conditionId));
for (const item of recognitions) supports(item.subject === 'scanner-finding' ? `finding:${item.occurrenceId}` : `setting:${item.scope}\u0000${item.identityKey}`, [item.clusterId]);
const confidenceRank = { catalogued: 0, recognized: 1, estimated: 2 } as const;
const severityRank = new Map(contextClusterSeverities.map(item => [item.clusterId, ({ critical: 0, high: 1, medium: 2, undetermined: 3 } as const)[item.severity]]));
const choosePrimary = (key: string, entry: ContextFindingAttachment | ContextSettingAttachment) => {
if (entry.clusterIds.length < 2) return;
const counts = support.get(key);
const ranked = [...entry.clusterIds].sort((left, right) =>
confidenceRank[entry.clusterConfidence?.[left] ?? 'catalogued'] - confidenceRank[entry.clusterConfidence?.[right] ?? 'catalogued']
|| (counts?.get(right) ?? 0) - (counts?.get(left) ?? 0)
|| (severityRank.get(left) ?? 3) - (severityRank.get(right) ?? 3)
|| left.localeCompare(right));
entry.clusterIds = [ranked[0]];
entry.secondaryClusterIds = ranked.slice(1);
if (entry.clusterConfidence) {
const confidence = entry.clusterConfidence[ranked[0]];
if (confidence) entry.clusterConfidence = { [ranked[0]]: confidence }; else delete entry.clusterConfidence;
}
};
for (const entry of findings.values()) choosePrimary(`finding:${entry.occurrenceId}`, entry);
for (const entry of settings.values()) choosePrimary(`setting:${entry.scope}\u0000${entry.identityKey}`, entry);
// Declared ATT&CK and D3FEND references: latest run per scan run, only findings of this scope, only declared and active IDs.
const scopedFindings = new Map((input.scannerFindings ?? []).map(finding => [finding.sourceResultId, finding]));
const groups = new Map<string, DeclaredReferenceGroup>();

View File

@ -0,0 +1,81 @@
import type { AssessmentContext, ContextClusterResult } from './context-engine';
import { CONTEXT_SEVERITY_CATALOG, contextClusterSeverities, type ContextSeverityReference } from './context-cluster-severity';
export { CONTEXT_SEVERITY_CATALOG, contextSeverityLabels } from './context-cluster-severity';
export interface ContextHeatmapCell {
cluster: ContextClusterResult;
reference: ContextSeverityReference;
/** Colour needs supporting Context evidence; automatic assignments count too. */
hasEvidence: boolean;
scannerDeviations: number;
gpoDeviations: number;
recognized: number;
estimated: number;
}
type HeatmapContext = Pick<AssessmentContext, 'clusters' | 'observations' | 'evaluations' | 'recognitions' | 'techniqueAssignments'>
& Partial<Pick<AssessmentContext, 'findings' | 'settings'>>;
function heatmapEvidence(context: HeatmapContext, cluster: ContextClusterResult) {
const conditionIds = new Set(cluster.conditions.map(condition => condition.conditionId));
const scanners = new Set(context.observations.filter(item => conditionIds.has(item.conditionId)
&& (item.outcome === 'fail' || item.outcome === 'warning')).map(item => item.occurrenceId));
const settings = new Set(context.evaluations.filter(item => item.matchedBy !== 'assigned-by-recognition'
&& conditionIds.has(item.conditionId) && item.verdict === 'deviation')
.map(item => `${item.scope}\u0000${item.identityKey}`));
for (const item of context.techniqueAssignments) {
if (item.clusterId === cluster.clusterId && (item.outcome === 'fail' || item.outcome === 'warning')) scanners.add(item.occurrenceId);
}
const assignments = context.recognitions.filter(item => item.clusterId === cluster.clusterId);
for (const item of assignments) {
if (item.subject === 'scanner-finding' && item.occurrenceId) scanners.add(item.occurrenceId);
if (item.subject === 'gpo-setting' && item.identityKey) settings.add(`${item.scope}\u0000${item.identityKey}`);
}
// One finding, one cluster (user decision 2026-10-04): a deviation counts only in its primary cluster, so the
// cells always add up to the unique finding total.
const findingPrimary = context.findings && new Map(context.findings.map(item => [item.occurrenceId, item.clusterIds[0]]));
const settingPrimary = context.settings && new Map(context.settings.map(item => [`${item.scope}\u0000${item.identityKey}`, item.clusterIds[0]]));
const elsewhere = (primary: Map<string, string | undefined> | undefined, key: string) => Boolean(primary?.has(key) && primary.get(key) !== cluster.clusterId);
for (const id of scanners) if (elsewhere(findingPrimary, id)) scanners.delete(id);
for (const id of settings) if (elsewhere(settingPrimary, id)) settings.delete(id);
return { scanners, settings, assignments: assignments.filter(item => item.subject === 'scanner-finding'
? !elsewhere(findingPrimary, item.occurrenceId ?? '') : !elsewhere(settingPrimary, `${item.scope}\u0000${item.identityKey}`)) };
}
/** Unique findings represented by the heatmap; cross-cluster membership never inflates the total. */
export function contextHeatmapFindingCount(context: HeatmapContext): number {
const scanners = new Set<string>();
const settings = new Set<string>();
for (const cluster of context.clusters) {
const evidence = heatmapEvidence(context, cluster);
for (const id of evidence.scanners) scanners.add(id);
for (const id of evidence.settings) settings.add(id);
}
return scanners.size + settings.size;
}
/** Presentation projection of existing Context results. Does not classify or evaluate findings. */
export function contextHeatmap(context: HeatmapContext): ContextHeatmapCell[] {
const references = new Map(contextClusterSeverities.map(reference => [reference.clusterId, reference]));
const cells: ContextHeatmapCell[] = context.clusters.map(cluster => {
const { scanners, settings, assignments } = heatmapEvidence(context, cluster);
return {
cluster,
reference: references.get(cluster.clusterId) ?? {
clusterId: cluster.clusterId, severity: 'undetermined',
rationale: 'No category severity has been reviewed for this cluster.',
qualification: `Severity catalog ${CONTEXT_SEVERITY_CATALOG.version} does not cover this cluster.`, sources: []
},
hasEvidence: cluster.satisfied > 0 || scanners.size + settings.size > 0,
scannerDeviations: scanners.size,
gpoDeviations: settings.size,
recognized: assignments.filter(item => item.confidence === 'recognized').length,
estimated: assignments.filter(item => item.confidence === 'estimated').length
};
});
// Sort by the colour actually displayed; unpopulated and undetermined cells are grey.
const rank = (cell: ContextHeatmapCell) => !cell.hasEvidence ? 3
: ({ critical: 0, high: 1, medium: 2, undetermined: 3 } as const)[cell.reference.severity];
return cells.sort((left, right) => rank(left) - rank(right));
}

View File

@ -0,0 +1,46 @@
import type { AssessmentContext } from './context-engine';
import { contextHeatmap, contextHeatmapFindingCount, CONTEXT_SEVERITY_CATALOG } from './context-heatmap';
/** Published, frozen Context results. Consumers never import Context algorithms or catalog internals. */
export function publishContextReport(context: AssessmentContext) {
const deviations = new Set([
...context.observations.filter(item => item.outcome === 'fail' || item.outcome === 'warning').map(item => item.occurrenceId),
...context.techniqueAssignments.filter(item => item.outcome === 'fail' || item.outcome === 'warning').map(item => item.occurrenceId),
...context.recognitions.filter(item => item.subject === 'scanner-finding').map(item => item.occurrenceId!)
]);
const settings = new Set(context.evaluations.filter(item => item.verdict === 'deviation').map(item => `${item.scope}\u0000${item.identityKey}`));
const clusters = contextHeatmap(context).map(cell => {
const scanner = context.findings.filter(item => deviations.has(item.occurrenceId) && item.clusterIds.includes(cell.cluster.clusterId));
const gpo = context.settings.filter(item => settings.has(`${item.scope}\u0000${item.identityKey}`) && item.clusterIds.includes(cell.cluster.clusterId));
const confidence = { catalogued: 0, recognized: 0, estimated: 0 };
if (cell.cluster.clusterId !== 'other-deviations') {
for (const item of [...scanner, ...gpo]) confidence[item.clusterConfidence?.[cell.cluster.clusterId] ?? 'catalogued']++;
}
return {
id: cell.cluster.clusterId, label: cell.cluster.label.en, description: cell.cluster.description.de,
severity: cell.reference.severity, hasEvidence: cell.hasEvidence,
scanner: scanner.length, gpo: gpo.length, confidence,
tools: [...new Set(scanner.map(item => item.tool))].sort(),
findingKeys: [...new Set([
...scanner.map(item => `scanner:${item.occurrenceId}`),
...gpo.map(item => `gpo:${item.scope}\u0000${item.identityKey}`)
])].sort(),
resultIds: [...new Set([
...scanner.map(item => item.resultProjectionId),
...gpo.flatMap(item => context.evaluations.filter(evaluation => evaluation.scope === item.scope && evaluation.identityKey === item.identityKey
&& evaluation.verdict === 'deviation').flatMap(evaluation => evaluation.resultProjectionIds))
])].sort(),
sources: cell.reference.sources.map(source => ({ ...source }))
};
});
return {
version: 1 as const, projectId: context.projectId, assessmentIds: [...context.assessmentIds].sort(),
scanRunIds: [...(context.scanRunIds ?? [])].sort(), repositoryRevision: context.repositoryRevision,
generatedAt: context.generatedAt, engineVersion: context.engine.version, catalogVersion: context.catalog.version,
severityCatalogVersion: CONTEXT_SEVERITY_CATALOG.version, vocabularyVersion: context.recognitionVocabulary.version,
findingCount: contextHeatmapFindingCount(context),
clusters, sharedSettingLevers: context.levers.filter(item => item.clusters > 1).map(item => ({
settings: 1, conditions: item.satisfiedConditions, clusters: item.clusters, backups: item.backupInstanceIds.length
}))
};
}

View File

@ -31,6 +31,37 @@ export class ContextStateChangedError extends Error {
}
}
/** Reuse large reads only inside one revision-checked attempt; callers receive independent source values. */
export function reuseContextReads(source: ContextReadPort): ContextReadPort {
function memoize<A extends unknown[], T>(read: (...args: A) => Promise<T>) {
const reads = new Map<string, Promise<T>>();
return async (...args: A): Promise<T> => {
const key = JSON.stringify(args);
let pending = reads.get(key);
if (!pending) {
pending = Promise.resolve().then(() => read(...args));
reads.set(key, pending);
void pending.catch(() => { if (reads.get(key) === pending) reads.delete(key); });
}
return structuredClone(await pending);
};
}
return {
// Delegate explicitly: a read port may be a class with prototype methods.
currentRevision: () => source.currentRevision(),
queryUnifiedResults: query => source.queryUnifiedResults(query),
listGpoPolicyAnalyses: projectId => source.listGpoPolicyAnalyses(projectId),
listReferenceRuns: projectId => source.listReferenceRuns(projectId),
getEnvironmentSnapshot: query => source.getEnvironmentSnapshot(query),
listPolicyEvaluations: memoize(projectId => source.listPolicyEvaluations(projectId)),
...(source.listAssessments ? { listAssessments: memoize(projectId => source.listAssessments!(projectId)) } : {}),
...(source.loadAssessmentModel ? { loadAssessmentModel: memoize(assessmentId => source.loadAssessmentModel!(assessmentId)) } : {}),
...(source.listMicrosoftSecurityBaselineAnalyses ? {
listMicrosoftSecurityBaselineAnalyses: memoize(baselineId => source.listMicrosoftSecurityBaselineAnalyses!(baselineId))
} : {})
};
}
const fieldOf = (record: FindingRecord, label: string) =>
record.fields.find(field => field.label.trim().toLowerCase() === label)?.value?.trim() ?? '';
@ -68,12 +99,13 @@ async function rawDataRecords(port: ContextReadPort, scope: AssessmentContextSco
* state: a revision change during the read repeats it instead of mixing states. Nothing is written.
*/
export async function createAssessmentContext(
port: ContextReadPort,
source: ContextReadPort,
scope: AssessmentContextScope,
catalog: AssessmentContextCatalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG,
attempts = 3
): Promise<AssessmentContext> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const port = reuseContextReads(source);
const before = await port.currentRevision();
const [observations, scannerFindings, evaluations, analyses, referenceRuns, storedEvaluations] = await Promise.all([
queryAllUnifiedResults(query => port.queryUnifiedResults(query), {

View File

@ -1,25 +1,82 @@
import { useEffect, useState, useSyncExternalStore } from 'react';
import { activitySnapshot, isActiveActivity, clearFinishedActivities, subscribeActivities } from '../platform/activity-store';
import { activitySnapshot, activityStatusSnapshot, isActiveActivity, clearCompletedActivities, dismissActivity, subscribeActivities, type Activity } from '../platform/activity-store';
export function ActivityCenter() {
function duration(item: Activity, now: number) {
const ms = Math.max(0, (item.endedAt ?? now) - item.startedAt);
return ms < 1000 ? '<1s' : `${Math.floor(ms / 1000)}s`;
}
function ActivityRow({ item, now }: { item: Activity; now: number }) {
const state = { queued: 'Waiting', running: 'Running', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled' }[item.state];
return <li data-state={item.state}>
<div className="activity-operation"><strong>{item.label}</strong><span>{item.area}</span></div>
<div className="activity-state"><span>{item.state === 'failed' ? '! ' : ''}{state} · {duration(item, now)}</span>
{(item.state === 'failed' || item.state === 'cancelled') && <button className="btn" type="button" aria-label={`Dismiss ${item.label}`} onClick={() => dismissActivity(item.id)}>Dismiss</button>}
</div>
</li>;
}
export function ActivityCenter({ onReload = () => window.location.reload() }: { onReload?: () => void } = {}) {
const activities = useSyncExternalStore(subscribeActivities, activitySnapshot, activitySnapshot);
const running = activities.filter(item => isActiveActivity(item));
const failed = activities.filter(item => item.state === 'failed').length;
const status = useSyncExternalStore(subscribeActivities, activityStatusSnapshot, activityStatusSnapshot);
const active = activities.filter(isActiveActivity);
const history = activities.filter(item => !isActiveActivity(item));
const [expanded, setExpanded] = useState(true);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [now, setNow] = useState(Date.now());
useEffect(() => { if (!running.length) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [running.length]);
if (!activities.length) return null;
const waiting = running.filter(item => item.state === 'queued').length;
const summary = running.length ? `${running.length - waiting} running${waiting ? ` · ${waiting} waiting` : ''}` : failed ? `${failed} failed` : 'All tasks finished';
useEffect(() => { if (!active.length) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [active.length]);
useEffect(() => {
if (!status.generation) return;
setExpanded(true);
setHistoryExpanded(false);
if (!status.ready) return;
const timer = setTimeout(() => { setExpanded(false); setHistoryExpanded(false); }, 5000);
return () => clearTimeout(timer);
}, [status.ready, status.generation]);
if (!activities.length && !status.generation) return null;
const waiting = active.filter(item => item.state === 'queued').length;
const summary = active.length
? [active.length - waiting ? `${active.length - waiting} running` : '', waiting ? `${waiting} waiting` : ''].filter(Boolean).join(' · ')
: status.ready ? 'Ready' : status.failed ? 'Not ready · Loading incomplete' : status.cancelled ? 'Not ready · Loading cancelled' : 'No active tasks';
const reloadControl = !active.length && (status.failed || status.cancelled) && !status.ready
? <button className="btn activity-reload" type="button" aria-label="Reload application" title="Reload application" onClick={onReload}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M20 7v5h-5M20 12a8 8 0 1 0-2.3 5.7" />
</svg>
</button> : null;
if (!expanded) return <div className="activity-minimized">
<button className="btn activity-compact-toggle" type="button" aria-label={`${summary} — Open activity history`} aria-expanded={false}
onClick={() => { setExpanded(true); setHistoryExpanded(true); }}>
{status.ready && <span className="activity-ready-dot" aria-hidden="true" />}
<span role="status" aria-live="polite">{summary}</span>
</button>
{reloadControl}
</div>;
const groups = new Map<string, Activity[]>();
for (const item of [...history].reverse()) {
const key = JSON.stringify([item.area, item.label, item.state]);
groups.set(key, [...(groups.get(key) ?? []), item]);
}
return <aside className="activity-center" aria-label="Application activity">
<div className="activity-center-header">
<button className="btn" type="button" aria-expanded={expanded} aria-controls="activity-items" onClick={() => setExpanded(!expanded)}>Activity · {summary}</button>
<button className="btn" type="button" onClick={clearFinishedActivities} disabled={activities.every(item => isActiveActivity(item))}>Clear finished</button>
<button className="btn activity-toggle" type="button" aria-expanded={expanded} aria-controls="activity-items" onClick={() => setExpanded(!expanded)}>{status.ready && <span className="activity-ready-dot" aria-hidden="true" />}Activity · {summary}<span aria-hidden="true">{expanded ? '⌃' : '⌄'}</span></button>
<div className="activity-header-actions">
{!!history.length && <button className="btn" type="button" aria-expanded={expanded && historyExpanded} aria-controls="activity-history" onClick={() => { setExpanded(true); setHistoryExpanded(true); }}>History ({history.length})</button>}
{reloadControl}
{status.ready && <button className="btn" type="button" aria-label="Minimize activity" title="Minimize activity" onClick={() => { setExpanded(false); setHistoryExpanded(false); }}><span aria-hidden="true">×</span></button>}
</div>
<span className="activity-center-status" role="status" aria-live="polite">{running.length ? `${running.at(-1)!.area} — ${running.at(-1)!.label}` : summary}</span>
{expanded && <ul id="activity-items">{[...running, ...activities.filter(item => !isActiveActivity(item)).reverse()].map(item => <li key={item.id} data-state={item.state}>
<div><strong>{item.area}</strong><span>{item.label}</span></div>
<span>{({queued:'Waiting',running:'Running',completed:'Completed',failed:'Failed',cancelled:'Cancelled'})[item.state]} · {Math.max(0, Math.floor(((item.endedAt ?? now) - item.startedAt) / 1000))}s</span>
</li>)}</ul>}
</div>
<span className="sr-only" role="status" aria-live="polite">{summary}</span>
{expanded && <div id="activity-items" className="activity-center-body">
{status.ready && <p className="activity-ready">All tasks completed.</p>}
{!!active.length && <section aria-label="Running and waiting"><h4>Running{waiting ? ' / Waiting' : ''}</h4><ul>{[...active.filter(item => item.state === 'running'), ...active.filter(item => item.state === 'queued')].map(item => <ActivityRow key={item.id} item={item} now={now} />)}</ul></section>}
{!!history.length && <details id="activity-history" className="activity-history" open={historyExpanded} onToggle={event => setHistoryExpanded(event.currentTarget.open)}><summary>History ({history.length})</summary>
{history.some(item => item.state === 'completed') && <button className="btn activity-clear" type="button" onClick={clearCompletedActivities}>Clear completed</button>}
<ul>{[...groups].map(([key, items]) => items.length === 1 ? <ActivityRow key={key} item={items[0]} now={now} /> : <li className="activity-group" key={key}>
<details><summary><strong>{items[0].label}</strong><span>{items[0].area} · {items.length} {items[0].state}</span></summary>
<ul>{items.map(item => <ActivityRow key={item.id} item={item} now={now} />)}</ul>
</details>
</li>)}</ul>
</details>}
</div>}
</aside>;
}

View File

@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
interface FilterEmptyStateProps {
title: string;
children: ReactNode;
onReset(): void;
onSearchAll(): void;
className?: string;
}
/** A small, local SVG companion for a completed search with no matches. */
export function FilterEmptyState({ title, children, onReset, onSearchAll, className = '' }: FilterEmptyStateProps) {
return <div className={`filter-empty-state ${className}`} role="status">
<svg className="filter-search-figure" viewBox="0 0 96 80" aria-hidden="true" focusable="false">
<g className="filter-search-look">
<path className="filter-search-body" d="M28 22h8v-8h20v8h8v8h8v24h-8v8H28v-8h-8V30h8z"/>
<path className="filter-search-body" d="M28 60h12v8H28v6H16v-8h12zm24 0h12v6h12v8H64v-6H52zM16 34h8v8H12v12H4V42h12z"/>
<path className="filter-search-eyes" d="M36 34h6v8h-6zm16 0h6v8h-6z"/>
<path className="filter-search-mouth" d="M42 49h10"/>
<g className="filter-search-lens">
<path d="M65 51l8-7m7 3 9 9"/>
<circle cx="75" cy="36" r="13"/>
<path className="filter-search-glint" d="M69 31l5-3"/>
</g>
</g>
</svg>
<h3>{title}</h3>
<p>{children}</p>
<div className="filter-empty-actions">
<button className="btn primary" type="button" onClick={onReset}>Reset filters</button>
<button className="btn" type="button" onClick={onSearchAll}>Search all Findings</button>
</div>
</div>;
}

View File

@ -0,0 +1,123 @@
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from './environment-context';
import { environmentCoverage } from './environment-context';
import { cloudInformationFacts, type CloudInformationFact } from './cloud-information';
/** Published Core contract. Internal analysis data: Result DB/export disclosure still applies. */
export const CLIENT_INFORMATION_VERSION = '1.1.0';
export const CLIENT_INFORMATION_AREAS = {
directory: 'Directory', inventory: 'Inventory', resilience: 'Resilience',
'password-policy': 'Password policy', certificates: 'Certificates & PKI',
'cloud-access': 'Cloud sign-in configuration', 'cloud-roles': 'Cloud role assignments',
'cloud-apps': 'Cloud application consent', 'cloud-sharing': 'Cloud external access & sharing',
'source-metadata': 'Source metadata', other: 'Other retained information'
} as const;
export type ClientInformationArea = keyof typeof CLIENT_INFORMATION_AREAS;
export interface ClientInformationQuery extends EnvironmentSnapshotQuery {
areas?: ClientInformationArea[];
properties?: string[];
subjectTypes?: string[];
subjectIds?: string[];
toolIds?: string[];
states?: EnvironmentCollectionState[];
search?: string;
offset?: number;
limit?: number;
sectionOffset?: number;
sectionLimit?: number;
}
export interface ClientInformationResult extends EnvironmentSnapshot {
/** Source-backed facts for the full filtered scope, independent of page size. */
cloudFacts: CloudInformationFact[];
contractVersion: typeof CLIENT_INFORMATION_VERSION;
purpose: 'internal-analysis';
repositoryRevision: number;
scopeMode: 'current-imports' | 'including-history';
totalObservations: number;
totalSections: number;
offset: number;
limit: number;
sectionOffset: number;
sectionLimit: number;
/** Additional exact source-link targets, included beyond the primary section page. */
linkedSectionIds: string[];
}
export function clientInformationArea(item: EnvironmentObservation | ScannerReportSection): ClientInformationArea {
const declared = item.attributes.clientInformationArea;
if (typeof declared === 'string' && Object.hasOwn(CLIENT_INFORMATION_AREAS, declared)) return declared as ClientInformationArea;
const property = 'property' in item ? item.property : '';
const subject = 'subject' in item ? item.subject.type : '';
const section = item.sectionKey?.toLowerCase() ?? '';
if (/^cloud\.(?:authentication|conditional_access)\./.test(property)) return 'cloud-access';
if (/^cloud\.roles\./.test(property)) return 'cloud-roles';
if (/^cloud\.consent\./.test(property)) return 'cloud-apps';
if (/^cloud\.(?:guests|sharing)\./.test(property)) return 'cloud-sharing';
if (/^(backup|kerberos\.krbtgt|laps|logging\.wef)\./.test(property) || property === 'domain.recycle_bin_enabled') return 'resilience';
if (/^(domain|forest|entra)\./.test(property) || /^domain-information(?:\.|$)/.test(subject) || section === 'sectiondomaininformation') return 'directory';
if (/^(host|operating_system)\./.test(property) || /^(computer-information|user-information|admin-groups|trusts-details)(?:\.|$)/.test(subject)
|| ['sectioncomputerinformation', 'sectionuserinformation', 'sectionadmingroups', 'sectiontrustsdetails'].includes(section)) return 'inventory';
if (/^password-policies(?:\.|$)/.test(subject) || section === 'sectionpasswordpolicies') return 'password-policy';
if (/^pki(?:\.|$)/.test(subject) || section === 'sectionpki') return 'certificates';
if (/^(report|risk|purple_knight|benchmark|maester)\./.test(property)
|| ['maester-report', 'purple-knight-report', 'purple-knight-assessment', 'benchmark-assessment'].includes(subject)
|| /^(?:maester-context-|purple-knight-assessment-summary|cis-cat-)/.test(section)) return 'source-metadata';
return 'other';
}
const includes = (values: readonly string[] | undefined, value: string | undefined) => !values?.length || values.includes(value ?? '');
const sectionIdentity = (item: { scanRunId: string; importFileId: string; sectionKey?: string }) =>
JSON.stringify([item.scanRunId, item.importFileId, item.sectionKey]);
const matchesSearch = (search: string, values: unknown[]) => !search || values.some(value =>
(typeof value === 'string' ? value : JSON.stringify(value) ?? '').toLocaleLowerCase('en-US').includes(search));
/** Deterministic selection only: never merges observations, infers configuration or resolves conflicts. */
export function selectClientInformation(snapshot: EnvironmentSnapshot, query: ClientInformationQuery, repositoryRevision: number): ClientInformationResult {
const search = query.search?.trim().toLocaleLowerCase('en-US') ?? '';
const observations = snapshot.observations.filter(item =>
includes(query.areas, clientInformationArea(item)) && includes(query.properties, item.property)
&& includes(query.subjectTypes, item.subject.type) && includes(query.subjectIds, item.subject.identity)
&& includes(query.toolIds, item.tool.id) && includes(query.states, item.state)
&& matchesSearch(search, [item.label, item.property, item.subject, item.originalValue]));
observations.sort((a, b) => a.property.localeCompare(b.property, 'en') || a.id.localeCompare(b.id, 'en'));
const observedSections = new Set(observations.map(sectionIdentity));
const precise = Boolean(query.properties?.length || query.subjectTypes?.length || query.subjectIds?.length);
// Related sections remain complete source evidence, not a second set of matching observations.
const sections = snapshot.sections.filter(item => observedSections.has(sectionIdentity(item)) || (!precise
&& includes(query.areas, clientInformationArea(item)) && includes(query.toolIds, item.tool.id) && includes(query.states, item.state)
&& matchesSearch(search, [item.label, item.prose, item.tables])));
sections.sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id, 'en'));
const offset = query.offset ?? 0;
const limit = query.limit ?? 50;
const sectionOffset = query.sectionOffset ?? 0;
const sectionLimit = query.sectionLimit ?? 10;
const page = sections.slice(sectionOffset, sectionOffset + sectionLimit);
const linkedSectionIds: string[] = [];
// Page primary sections first, then retain unambiguous native link targets in the same run/file.
const selected = new Set(page.map(sectionIdentity));
const byIdentity = new Map<string, ScannerReportSection[]>();
for (const item of snapshot.sections) {
const id = sectionIdentity(item);
byIdentity.set(id, [...(byIdentity.get(id) ?? []), item]);
}
for (let index = 0; index < page.length; index++) {
const parent = page[index];
const targets = [...(parent.detailLinks ?? []).map(link => link.targetId),
...parent.tables.flatMap(table => (table.cellLinks ?? []).map(link => link.targetId))];
for (const target of targets) {
const id = sectionIdentity({ ...parent, sectionKey: target });
const matches = byIdentity.get(id);
if (matches?.length === 1 && !selected.has(id)) {
selected.add(id); page.push(matches[0]); linkedSectionIds.push(matches[0].id);
}
}
}
return structuredClone({
...snapshot, contractVersion: CLIENT_INFORMATION_VERSION, purpose: 'internal-analysis', repositoryRevision,
scopeMode: query.includeSuperseded ? 'including-history' : 'current-imports',
totalObservations: observations.length, totalSections: sections.length, offset, limit, sectionOffset, sectionLimit,
observations: observations.slice(offset, offset + limit), sections: page, linkedSectionIds,
coverage: environmentCoverage(sections, observations), cloudFacts: cloudInformationFacts({ observations })
});
}

View File

@ -0,0 +1,164 @@
import type { ClientInformationArea } from './client-information';
import type { EnvironmentObservation, EnvironmentSnapshot } from './environment-context';
import { extractMarkdownTables } from '../shared/markdown';
/** Read-only semantic projection of retained native result fields, not live tenant state. */
export const CLOUD_INFORMATION_MAPPING_VERSION = '1.0.0';
export interface CloudInformationFact {
property: string;
area: ClientInformationArea;
label: string;
value: string | number;
displayValue: string;
scope: 'reported-setting' | 'reported-subset';
observationId: string;
scanRunId: string;
importFileId: string;
sourceRuleId: string;
subjectIdentity?: string;
observedAt?: string;
source: EnvironmentObservation['source'];
mappingVersion: typeof CLOUD_INFORMATION_MAPPING_VERSION;
}
// Exact IDs and native output templates verified against official Maester generators.
// Offline semantics and public sources: docs/CLIENT_INFORMATION.md.
const SETTINGS: Record<string, [ClientInformationArea, string, string]> = {
'EIDSCA.AF01': ['cloud-access', 'cloud.authentication.fido2.state', 'FIDO2'],
'EIDSCA.AM01': ['cloud-access', 'cloud.authentication.authenticator.state', 'Authenticator'],
'EIDSCA.AT01': ['cloud-access', 'cloud.authentication.temporary_access_pass.state', 'Temporary Access Pass'],
'EIDSCA.AV01': ['cloud-access', 'cloud.authentication.voice.state', 'Voice authentication'],
'EIDSCA.AP08': ['cloud-apps', 'cloud.consent.user_policy', 'User consent'],
'EIDSCA.CR01': ['cloud-apps', 'cloud.consent.admin_request.enabled', 'Admin consent requests'],
'EIDSCA.AP10': ['cloud-apps', 'cloud.consent.app_registration.allowed', 'App registration'],
'EIDSCA.AP04': ['cloud-sharing', 'cloud.sharing.guest_invitation', 'Guest invitations'],
'EIDSCA.AP07': ['cloud-sharing', 'cloud.sharing.guest_role', 'Guest access'],
'EIDSCA.ST08': ['cloud-sharing', 'cloud.sharing.guest_group_owner.allowed', 'Guest group owners'],
'EIDSCA.ST09': ['cloud-sharing', 'cloud.sharing.guest_group_access.allowed', 'Guest group access']
};
function nativeSetting(text: string): string | undefined {
const first = text.trim();
if (/^Your tenant is \*\*not configured explicitly\*\*\./.test(first)) return 'not configured explicitly';
const actual = /^Your tenant is configured as \*\*([^*\r\n]+)\*\*\./.exec(first)
?? /^Well done\. The configuration in your tenant and recommended value is \*\*'([^*\r\n]+)'\*\* for \*\*/.exec(first);
return actual?.[1].trim() || undefined;
}
function displaySetting(id: string, value: string): string {
const key = value.toLowerCase();
if (key === 'true') return 'enabled';
if (key === 'false') return 'disabled';
if (id === 'EIDSCA.AP08') {
if (key === 'managepermissiongrantsforself.microsoft-user-default-low') return 'low-impact permissions only';
if (key === 'managepermissiongrantsforself.microsoft-user-default-legacy') return 'all non-admin permissions';
}
if (id === 'EIDSCA.AP07') {
if (key === '2af84b1e-32c8-42b7-82bc-daa82404023b') return 'Restricted Guest User';
if (key === '10dae51f-b6af-4016-8d66-8c2a99b929b3') return 'Guest User';
}
return value;
}
/** Each fact retains its own source/scope. Never add across tests, runs or tenants. */
export function cloudInformationFacts(snapshot: Pick<EnvironmentSnapshot, 'observations'> | null | undefined): CloudInformationFact[] {
const facts: CloudInformationFact[] = [];
for (const observation of snapshot?.observations ?? []) {
if (observation.state !== 'observed' || observation.tool.id !== 'maester'
|| observation.attributes.informationKind !== 'reported-test-evidence'
|| typeof observation.originalValue !== 'string') continue;
const id = observation.attributes.sourceRuleId;
if (typeof id !== 'string') continue;
const text = observation.originalValue;
const add = (area: ClientInformationArea, property: string, label: string, value: string | number,
displayValue = String(value), scope: CloudInformationFact['scope'] = 'reported-setting') => {
facts.push({ area, property, label, value, displayValue, scope, observationId: observation.id,
scanRunId: observation.scanRunId, importFileId: observation.importFileId, sourceRuleId: id,
subjectIdentity: observation.subject.identity, observedAt: observation.observedAt,
source: observation.source, mappingVersion: CLOUD_INFORMATION_MAPPING_VERSION });
};
const setting = SETTINGS[id];
if (setting) {
const value = nativeSetting(text);
if (value !== undefined) add(...setting, value, displaySetting(id, value));
// Only an explicit actual-value column is configuration evidence. Expected values are ignored.
for (const table of extractMarkdownTables(text)) {
if (table.headers.length !== 2 || table.headers[0] !== 'Setting' || table.headers[1] !== 'Actual Value') continue;
if (table.rows.length === 1 && table.rows[0][0] === setting[2] && table.rows[0][1].trim()) {
const raw = table.rows[0][1].trim(); add(...setting, raw, displaySetting(id, raw));
}
}
}
if (id === 'CISA.MS.AAD.7.4') {
if (text.trim() === 'Well done. Your tenant has no active assignments without expiration to privileged roles.') {
add('cloud-roles', 'cloud.roles.permanent.reported_count', 'Reported permanent assignments', 0, '0', 'reported-subset');
} else if (text.trim().startsWith('Your tenant has active assignments without expiration to privileged roles.')) {
for (const table of extractMarkdownTables(text)) {
if (table.headers.join('|') !== 'Role|Principal Type|Display Name|Status' || !table.rows.length
|| !table.rows.every(row => row.length === 4 && row.slice(0, 3).every(cell => cell.trim()) && row[3].trim() === '❌ No Expiration')) continue;
add('cloud-roles', 'cloud.roles.permanent.reported_count', 'Reported permanent assignments', table.rows.length, String(table.rows.length), 'reported-subset');
}
}
}
// The helper also uses this sentence for other alerts; only MT.1031 has PIM-management semantics.
if (id === 'MT.1031' && text.trim() === 'All privileged role assignments are managed by PIM. Well done!') {
add('cloud-roles', 'cloud.roles.pim_management', 'Reported privileged roles', 'managed by PIM');
}
if (id === 'MT.1007' && text.trim() === 'No Conditional Access policy requires multi-factor authentication for all users.') {
add('cloud-access', 'cloud.conditional_access.all_user_mfa.reported_count', 'Reported all-user MFA policies', 0, '0', 'reported-subset');
}
if (id === 'MT.1007' && text.trim().startsWith('The following Conditional Access policies require multi-factor authentication for all users:')) {
const lines = text.trim().split(/\r?\n/).slice(1).filter(line => line.trim());
const links = lines.map(line => /^\s*- \[[^\]\r\n]+\]\(https:\/\/[^\s)]+#view\/Microsoft_AAD_ConditionalAccess\/PolicyBlade\/policyId\/([^\s)]+)\)\s*$/.exec(line));
if (links.length && links.every(link => link !== null)) {
const count = new Set(links.map(link => link![1])).size;
add('cloud-access', 'cloud.conditional_access.all_user_mfa.reported_count', 'Reported all-user MFA policies', count, String(count), 'reported-subset');
}
}
if (['MT.1025', 'MT.1026', 'MT.1027', 'MT.1028'].includes(id)) {
const types: Record<string, [string, string]> = { 'MT.1025': ['ExternalUser', 'external'], 'MT.1026': ['HybridUser', 'hybrid'],
'MT.1027': ['ServicePrincipalClientSecret', 'app secret'], 'MT.1028': ['UserMailbox', 'mail-enabled'] };
const [type, label] = types[id];
if (text.trim().startsWith(`These directory role assignments for ${type} exists:`)) {
const lines = text.trim().split(/\r?\n/).slice(1).filter(line => line.trim());
if (lines.length && lines.every(line => /^\s*- \[[^\]\r\n]+\]\(https:\/\/[^\s)]+\) with .+ on scope .+\s*$/.test(line))) {
add('cloud-roles', `cloud.roles.${type.toLowerCase()}.reported_count`, `Reported ${label} assignments`, lines.length, String(lines.length), 'reported-subset');
}
}
}
if (id === 'CISA.MS.SHAREPOINT.1.1') {
const current = /^\* Current setting: `([^`\r\n]+)`\s*$/m.exec(text);
if (current && text.trim().startsWith('Your tenant does not restrict SharePoint Online sharing.')) {
add('cloud-sharing', 'cloud.sharing.sharepoint.capability', 'SharePoint sharing', current[1]);
}
}
}
return facts.sort((a, b) => a.property.localeCompare(b.property, 'en') || a.observationId.localeCompare(b.observationId, 'en')
|| String(a.value).localeCompare(String(b.value), 'en'));
}
/** One compact overview per area; all facts remain available in the published read result. */
export function cloudInformationOverview(facts: CloudInformationFact[], area: ClientInformationArea): string[] {
const selected = facts.filter(fact => fact.area === area);
const scopes = new Set(selected.map(fact => JSON.stringify([fact.scanRunId, fact.subjectIdentity ?? fact.importFileId])));
if (scopes.size > 1) return ['Multiple scan scopes — see Results'];
const properties = [...new Set(selected.map(fact => fact.property))];
const priority = ['cloud.authentication.fido2.state', 'cloud.consent.admin_request.enabled', 'cloud.sharing.guest_role'];
properties.sort((a, b) => {
const rank = (value: string) => priority.includes(value) ? priority.indexOf(value) : priority.length;
return rank(a) - rank(b) || a.localeCompare(b, 'en');
});
// A conflicting value must never disappear behind the compact default selection.
const conflict = properties.find(property => new Set(selected.filter(fact => fact.property === property).map(fact => fact.displayValue)).size > 1);
return (conflict ? [conflict] : properties.slice(0, 1)).map(property => {
const values = selected.filter(fact => fact.property === property);
const variants = [...new Set(values.map(fact => fact.displayValue))];
const fact = values[0];
if (variants.length > 1) return `${fact.label}: conflicting source values`;
if (property === 'cloud.roles.permanent.reported_count') return `Permanent: ${fact.value} reported assignments`;
if (property === 'cloud.consent.admin_request.enabled') return `Admin requests: ${fact.displayValue}`;
if (property === 'cloud.sharing.guest_role' && fact.displayValue === 'Restricted Guest User') return 'Guests: restricted role';
if (property === 'cloud.sharing.guest_role' && fact.displayValue === 'Guest User') return 'Guests: standard guest role';
return `${fact.label}: ${variants[0]}`;
});
}

View File

@ -93,6 +93,8 @@ export interface EnvironmentSnapshotQuery {
projectId: string;
assessmentId?: string;
scanRunIds?: string[];
/** Explicit opt-in to retained replaced imports; ordinary reads use current imports. */
includeSuperseded?: boolean;
}
export function environmentCoverage(

View File

@ -39,7 +39,8 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
{ profileId: 'cis-cat', profileVersion: '1.3.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: ['evidence', 'presentation'], current: false },
{ profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true },
{ profileId: 'maester', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false },
{ profileId: 'maester', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.0.0', presentationVersion: '1.1.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence', 'environment-context', 'presentation'], current: true }
{ profileId: 'maester', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.0.0', presentationVersion: '1.1.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence', 'environment-context', 'presentation'], current: false },
{ profileId: 'maester', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.0.0', presentationVersion: '1.1.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'recognition'], current: true }
] as const;
const manifestKey = (profileId: string, profileVersion: string) => `${profileId.trim().toLocaleLowerCase('en-US')}@${profileVersion.trim()}`;

View File

@ -0,0 +1,183 @@
import type { FindingRecord } from '../domain/finding';
import type { UnifiedResultProjection } from '../domain/unified-result';
import type {
SecurityDomainId, SecurityDomainMapping, SecurityDomainMethod, SecurityDomainRuleSet, SecurityDomainTier
} from '../profiles/security-domain-mapping';
import { cisCatSecurityDomains } from '../profiles/cis-cat/cis-cat-security-domains';
import { groupPolicySecurityDomains } from '../profiles/group-policy/group-policy-security-domains';
import { maesterSecurityDomains } from '../profiles/maester/maester-security-domains';
import { pingCastleSecurityDomains } from '../profiles/pingcastle/pingcastle-security-domains';
import { purpleKnightSecurityDomains } from '../profiles/purple-knight/purple-knight-security-domains';
export type { SecurityDomainId } from '../profiles/security-domain-mapping';
/**
* The single Security Domain classifier for Work, Client Info and reports (user decision
* 2026-10-04). It reads only Raw Data fields of a record, assigns every check exactly one
* of six domains and never reads the outcome, title, description or remediation prose.
*/
export const SECURITY_DOMAIN_CLASSIFIER_VERSION = '2.0.0';
export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain';
export const securityDomainCatalog = [
{ id: 'identity', label: 'Identity & Authentication', glyph: 'user' },
{ id: 'privilege', label: 'Privileged Access & Delegation', glyph: 'shield' },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server' },
{ id: 'hardening', label: 'System Hardening', glyph: 'code' },
{ id: 'cloud', label: 'Cloud & Collaboration', glyph: 'cloud' },
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart' }
] as const satisfies ReadonlyArray<{ id: SecurityDomainId; label: string; glyph: string }>;
export const securityDomainMappings: ReadonlyArray<SecurityDomainMapping> = [
cisCatSecurityDomains, groupPolicySecurityDomains, maesterSecurityDomains,
pingCastleSecurityDomains, purpleKnightSecurityDomains
];
/** A source without a declared mapping is still assigned, visibly, by this global fallback. */
const GLOBAL_FALLBACK: SecurityDomainId = 'hardening';
export interface SecurityDomainAssignment {
domainId: SecurityDomainId;
tier: SecurityDomainTier;
method: SecurityDomainMethod | 'source-fallback' | 'global-fallback';
/** Raw Data field and the exact value that decided; absent for a pure source fallback. */
field?: string;
value?: string;
profileId?: string;
mappingVersion?: string;
}
/** Read access to the fields Raw Data shows, by case-insensitive label. */
export type SecurityDomainFieldReader = (label: string) => string;
const fold = (value: string) => value.trim().toLocaleLowerCase('en-US');
const compact = (value: string) => fold(value).replace(/\s+/g, '');
const HIVE_PREFIX = /^(?:hkey_local_machine|hkey_current_user|hklm|hkcu|machine|user)\\/i;
function mappingForTool(tool: string): SecurityDomainMapping | undefined {
const name = compact(tool.split(' · ')[0] ?? '');
if (!name) return undefined;
return securityDomainMappings.find(mapping => mapping.toolNames.some(candidate => name.startsWith(compact(candidate))));
}
function matchRuleSet(ruleSet: SecurityDomainRuleSet, raw: string): SecurityDomainId | undefined {
const value = ruleSet.method === 'registry-key' ? raw.trim().replace(HIVE_PREFIX, '') : raw.trim();
const folded = fold(value);
switch (ruleSet.match) {
case 'exact':
return ruleSet.entries.find(([key]) => fold(key) === folded)?.[1];
case 'list-item': {
// Plain lists and JSON-style tag arrays (`["ORCA","EXO"]`) alike.
const items = new Set(value.split(/[,;\n]/).map(item => fold(item.replace(/^[\s["']+|[\s\]"']+$/g, ''))).filter(Boolean));
return ruleSet.entries.find(([key]) => items.has(fold(key)))?.[1];
}
case 'prefix': {
let best: readonly [string, SecurityDomainId] | undefined;
for (const entry of ruleSet.entries) {
const key = fold(entry[0]);
const hit = ruleSet.boundary
? folded === key || folded.startsWith(key + ruleSet.boundary.toLocaleLowerCase('en-US'))
: folded.startsWith(key);
if (hit && (!best || key.length > best[0].length)) best = [key, entry[1]];
}
return best?.[1];
}
case 'section-path': {
const segments = value.split(' > ').map(fold).filter(Boolean);
let best: { depth: number; domain: SecurityDomainId } | undefined;
for (const [key, domain] of ruleSet.entries) {
const keySegments = key.split(' > ').map(fold);
if (keySegments.length > segments.length) continue;
if (keySegments.every((segment, index) => segments[index].startsWith(segment))
&& (!best || keySegments.length > best.depth)) best = { depth: keySegments.length, domain };
}
return best?.domain;
}
}
}
/**
* Assigns one check to exactly one domain from its Raw Data fields; the first tier with a hit
* decides. `toolId` names the source of a single-tool sheet whose rows carry no `Source: Scan`.
*/
export function classifySecurityDomain(read: SecurityDomainFieldReader, toolId?: string): SecurityDomainAssignment {
const mapping = mappingForTool(read('Source: Scan') || toolId || '');
if (!mapping) return { domainId: GLOBAL_FALLBACK, tier: 5, method: 'global-fallback' };
const provenance = { profileId: mapping.profileId, mappingVersion: mapping.version };
for (const ruleSet of mapping.ruleSets) {
if (ruleSet.when && fold(read(ruleSet.when.field)) !== fold(ruleSet.when.equals)) continue;
const labels: ReadonlyArray<string> = typeof ruleSet.field === 'string' ? [ruleSet.field] : ruleSet.field;
for (const label of labels) {
const value = read(label);
const domainId = value ? matchRuleSet(ruleSet, value) : undefined;
if (domainId) return { domainId, tier: ruleSet.tier, method: ruleSet.method, field: label, value, ...provenance };
}
}
const refinement = mapping.fallback.byField;
const refinementValue = refinement ? read(refinement.field) : '';
const refined = refinement && refinementValue
? refinement.entries.find(([key]) => fold(key) === fold(refinementValue))?.[1] : undefined;
return refined
? { domainId: refined, tier: 5, method: 'source-fallback', field: refinement!.field, value: refinementValue, ...provenance }
: { domainId: mapping.fallback.domain, tier: 5, method: 'source-fallback', ...provenance };
}
/** The fields Raw Data shows for a record (`RawDataView`): every field with a value. */
export function rawDataFieldReader(record: Pick<FindingRecord, 'fields'>): SecurityDomainFieldReader {
const values = new Map<string, string>();
for (const field of record.fields) {
const value = field.value?.trim();
const label = fold(field.label);
if (value && !values.has(label)) values.set(label, value);
}
return label => values.get(fold(label)) ?? '';
}
const recordAssignmentCache = new WeakMap<FindingRecord, Map<string, SecurityDomainAssignment>>();
export function securityDomainAssignmentForRecord(record: FindingRecord, toolId?: string): SecurityDomainAssignment {
let byTool = recordAssignmentCache.get(record);
if (!byTool) recordAssignmentCache.set(record, byTool = new Map());
let cached = byTool.get(toolId ?? '');
if (!cached) byTool.set(toolId ?? '', cached = classifySecurityDomain(rawDataFieldReader(record), toolId));
return cached;
}
export function securityDomainIdForRecord(record: FindingRecord, toolId?: string): SecurityDomainId {
return securityDomainAssignmentForRecord(record, toolId).domainId;
}
/**
* Unified result without a loaded Work record: the same classifier over the structured
* values the result carries. Callers that have the Work record use it instead, so the
* report and Work agree on every record they share.
*/
export function securityDomainIdForUnifiedResult(result: UnifiedResultProjection): SecurityDomainId {
const values = new Map<string, string>([
['source: scan', result.sourceKind.startsWith('policy-') ? 'group-policy' : result.sourceTool ?? ''],
['source rule id', result.sourceRuleId ?? ''],
['category', result.categories.length === 1 ? result.categories[0].categoryLabel : ''],
['policy type', result.policyDeliveryMechanism ?? '']
]);
return classifySecurityDomain(label => values.get(fold(label))?.trim() ?? '').domainId;
}
/** Assign every record once so distributions reconcile to the total. */
export function securityDomainBuckets(records: FindingRecord[], toolId?: string): Map<SecurityDomainId, FindingRecord[]> {
const buckets = new Map<SecurityDomainId, FindingRecord[]>(securityDomainCatalog.map(domain => [domain.id, []]));
records.forEach(record => buckets.get(securityDomainIdForRecord(record, toolId))!.push(record));
return buckets;
}
/** Per source: how many checks were assigned on each tier; a high tier-5 share means the mapping table needs entries. */
export function securityDomainTierCoverage(records: FindingRecord[]): Map<string, Map<SecurityDomainTier, number>> {
const coverage = new Map<string, Map<SecurityDomainTier, number>>();
for (const record of records) {
const assignment = securityDomainAssignmentForRecord(record);
const source = assignment.profileId ?? 'unmapped';
const tiers = coverage.get(source) ?? new Map<SecurityDomainTier, number>();
tiers.set(assignment.tier, (tiers.get(assignment.tier) ?? 0) + 1);
coverage.set(source, tiers);
}
return coverage;
}

View File

@ -0,0 +1,44 @@
import { useId, useMemo } from 'react';
import { useApp } from '../../app/AppProvider';
import type { AssessmentContextResponse } from '../../api/assessor-api-contract';
import { contextHeatmap, contextHeatmapFindingCount, contextSeverityLabels } from '../../assessment-context/context-heatmap';
import { useAssessmentContext } from './use-assessment-context';
import './context-heatmap.css';
/** Existing scoped Context request is shared with Work and the Finding Reader. */
export function ClientContextHeatmap({ onOpenCluster }: { onOpenCluster(clusterId: string): void }) {
const app = useApp();
const state = useAssessmentContext(Boolean(app.dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, app.dataset);
if (state.status === 'off' || state.status === 'unavailable') return null;
if (state.status === 'loading') return <section className="context-heatmap" aria-label="Context heatmap" aria-busy="true">
<h2>Context heatmap</h2><p role="status">Loading Context…</p>
</section>;
if (state.status === 'error') return <section className="context-heatmap" aria-label="Context heatmap">
<h2>Context heatmap</h2><p role="alert">Context could not be loaded. Reopen the client to retry.</p>
</section>;
return <ContextHeatmap context={state.context} onOpenCluster={onOpenCluster} />;
}
export function ContextHeatmap({ context, onOpenCluster }: { context: AssessmentContextResponse; onOpenCluster(clusterId: string): void }) {
const id = useId();
const cells = useMemo(() => contextHeatmap(context), [context]);
const findingCount = useMemo(() => contextHeatmapFindingCount(context), [context]);
return <section className="context-heatmap" aria-labelledby={`${id}-title`}>
<header className="context-heatmap__header">
<h2 id={`${id}-title`}>Context heatmap</h2>
<span className="context-heatmap__total" aria-label="Unique findings represented by the heatmap">Findings: {findingCount.toLocaleString('en-US')}</span>
</header>
<div className="context-heatmap__grid" aria-label="Context clusters">
{cells.map(({ cluster: item, reference, hasEvidence, scannerDeviations, gpoDeviations }) => <button
key={item.clusterId} type="button" className="context-heatmap__cell"
data-cluster={item.clusterId} data-severity={hasEvidence ? reference.severity : 'neutral'}
aria-label={`Open ${item.label.en} findings in Work (${scannerDeviations + gpoDeviations})`}
onClick={() => onOpenCluster(item.clusterId)}>
<span className="context-heatmap__category">Base Severity · {contextSeverityLabels[reference.severity]}</span>
<strong>{item.label.en}</strong>
<span className="context-heatmap__amount">{(scannerDeviations + gpoDeviations).toLocaleString('en-US')} {scannerDeviations + gpoDeviations === 1 ? 'finding' : 'findings'}</span>
</button>)}
</div>
</section>;
}

View File

@ -1,6 +1,7 @@
import type { AssessmentContextResponse } from '../../api/assessor-api-contract';
import type { FindingRecord } from '../../domain/finding';
import type { DynamicFacet } from '../search-filter/dynamic-facets';
import { publishContextReport } from '../../assessment-context/context-report-publication';
/**
* Work facet of the context layer (Phase 13E, decision (c)). It exists only while the layer is on and
@ -10,6 +11,7 @@ export const CONTEXT_CLUSTER_FACET_ID = 'context:cluster';
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
export interface ContextClusterIndex {
publication: ReturnType<typeof publishContextReport>;
facet: DynamicFacet;
/** The loaded clusters with their state, for the Pathfinder details (Phase 13E). */
clusters: AssessmentContextResponse['clusters'];
@ -34,6 +36,7 @@ export function contextClusterIndex(context: AssessmentContextResponse): Context
return identity ? bySetting.get(`${field(record, 'Scope')}\u0000${identity}`) ?? [] : [];
};
const index: ContextClusterIndex = {
publication: publishContextReport(context),
clusters: context.clusters,
facet: {
id: CONTEXT_CLUSTER_FACET_ID,

View File

@ -0,0 +1,25 @@
.context-heatmap { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
.context-heatmap h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.context-heatmap p { margin: var(--space-2) 0; font-size: var(--type-body-size); }
.context-heatmap__header { display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.context-heatmap__total { font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.context-heatmap [data-severity="critical"] { --context-heat: var(--sev-critical); }
.context-heatmap [data-severity="high"] { --context-heat: var(--sev-high); }
.context-heatmap [data-severity="medium"] { --context-heat: var(--sev-medium); }
.context-heatmap__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-compact-width)), var(--tile-compact-width)));
gap: var(--space-2); margin-top: var(--space-3); grid-auto-rows: 1fr;
}
.context-heatmap__cell {
display: flex; flex-direction: column; align-items: start; gap: var(--space-2);
padding: var(--space-3); min-width: 0; min-height: calc(var(--tile-h) * .75); height: auto;
border: var(--bw-emphasis) solid transparent; border-radius: var(--radius-sm);
background: var(--context-heat, var(--surface-panel-raised)); color: var(--sev-ink);
font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
}
.context-heatmap__cell[data-severity="neutral"], .context-heatmap__cell[data-severity="undetermined"] { color: var(--text-primary); }
.context-heatmap__cell:hover { border-color: currentColor; }
.context-heatmap__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
.context-heatmap__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); }
.context-heatmap__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
.context-heatmap__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); }

View File

@ -0,0 +1,111 @@
import { useEffect, useState, type FormEvent } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { CLIENT_INFORMATION_AREAS, type ClientInformationArea, type ClientInformationQuery, type ClientInformationResult } from '../../domain/client-information';
import type { EnvironmentObservation, EnvironmentSnapshotQuery } from '../../domain/environment-context';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
const api = new AssessorApiClient();
const textValue = (value: unknown) => typeof value === 'string' ? value : JSON.stringify(value, null, 2) ?? '—';
const preview = (item: EnvironmentObservation) => {
if (item.originalValue === '') return '""';
if (item.originalValue !== null && typeof item.originalValue === 'object') return 'Structured source value — open details';
const value = textValue(item.originalValue);
return value.length > 160 ? `${value.slice(0, 160)}…` : value;
};
/** On-demand UI consumer of the same published query contract engines use. */
export function ClientInformationBrowser({ scope, areas }: { scope: EnvironmentSnapshotQuery; areas?: ClientInformationArea[] }) {
const [area, setArea] = useState('');
const [search, setSearch] = useState('');
const [property, setProperty] = useState('');
const [subject, setSubject] = useState('');
const [query, setQuery] = useState<ClientInformationQuery>({ ...scope, areas });
const [result, setResult] = useState<ClientInformationResult>();
const [error, setError] = useState(false);
const [request, setRequest] = useState(0);
const [selected, setSelected] = useState<EnvironmentObservation>();
const scopeKey = JSON.stringify({ ...scope, areas });
const queryKey = JSON.stringify(query);
useEffect(() => {
let active = true;
setResult(undefined); setError(false); setSelected(undefined);
api.queryClientInformation({ ...query, ...scope, areas: query.areas?.length ? query.areas : areas }).then(value => {
if (active) setResult(value);
}).catch(() => { if (active) setError(true); });
return () => { active = false; };
}, [scopeKey, queryKey, request]);
const submit = (event: FormEvent) => {
event.preventDefault();
setQuery({ ...scope, areas: area ? [area as ClientInformationArea] : undefined,
search: search.trim() || undefined, properties: property.trim() ? [property.trim()] : undefined,
subjectIds: subject.trim() ? [subject.trim()] : undefined });
setRequest(value => value + 1);
};
return <div className="client-information-browser">
<p>Retained information for the selected client and scan scope. Source dates describe the imported state, not a live inventory.</p>
<form className="client-information-filters" onSubmit={submit}>
<label>Information area<select aria-label="Information area" value={area} onChange={event => setArea(event.target.value)}>
<option value="">All information</option>
{Object.entries(CLIENT_INFORMATION_AREAS).filter(([id]) => !areas || areas.includes(id as ClientInformationArea)).map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</select></label>
<label>Search<input type="search" maxLength={500} aria-label="Search Client Information" value={search} onChange={event => setSearch(event.target.value)} /></label>
<label>Property (exact)<input maxLength={500} aria-label="Client Information property" value={property} onChange={event => setProperty(event.target.value)} /></label>
<label>Object ID (exact)<input maxLength={500} aria-label="Client Information object" value={subject} onChange={event => setSubject(event.target.value)} /></label>
<button type="submit" className="btn">Apply</button>
<button type="button" className="btn" onClick={() => {
setArea(''); setSearch(''); setProperty(''); setSubject(''); setQuery({ ...scope, areas }); setRequest(value => value + 1);
}}>Reset</button>
</form>
{!result && !error && <p role="status">Loading Client Information…</p>}
{error && <div role="alert"><p>Client Information could not be loaded.</p><button className="btn" type="button" onClick={() => setRequest(value => value + 1)}>Retry</button></div>}
{result && <>
<p role="status">{result.totalObservations.toLocaleString('en-US')} matching observations · {result.totalSections.toLocaleString('en-US')} related source sections</p>
{result.totalObservations === 0 && <p>No retained observations match this selection. Missing information does not mean the setting is disabled or the inventory is empty.</p>}
{result.observations.length > 0 && <div className="client-env-evidence-scroll"><table className="client-information-table">
<thead><tr><th>Information</th><th>Original value</th><th>Collection state</th><th>Source</th><th>Details</th></tr></thead>
<tbody>{result.observations.map(item => <tr key={item.id}>
<td>{item.label}</td><td>{preview(item)}</td><td>{item.state}</td><td>{item.tool.name}</td>
<td><button className="btn" type="button" aria-label={`Details: ${item.label}`} aria-pressed={selected?.id === item.id}
onClick={() => setSelected(selected?.id === item.id ? undefined : item)}>Details</button></td>
</tr>)}</tbody>
</table></div>}
{result.totalObservations > result.limit && <nav className="client-information-pagination" aria-label="Client Information pages">
<button className="btn" disabled={result.offset === 0} onClick={() => setQuery({ ...query, offset: Math.max(0, result.offset - result.limit) })}>Previous observations</button>
<span>{result.offset + 1}–{Math.min(result.offset + result.limit, result.totalObservations)} / {result.totalObservations}</span>
<button className="btn" disabled={result.offset + result.limit >= result.totalObservations} onClick={() => setQuery({ ...query, offset: result.offset + result.limit })}>Next observations</button>
</nav>}
{selected && <section className="client-information-detail" aria-label="Client Information details">
<h3>{selected.label}</h3>
{(result.cloudFacts ?? []).filter(fact => fact.observationId === selected.id).map(fact => <p key={`${fact.property}:${fact.value}`}>
{fact.label}: {fact.displayValue} {fact.scope === 'reported-subset' && '(reported subset)'}
</p>)}
{selected.attributes.informationKind === 'reported-test-evidence' && <p>Reported test evidence. It may describe only the objects examined by this test; it is not a complete configuration inventory.</p>}
<dl className="client-env-facts">
<div><dt>Property</dt><dd>{selected.property}</dd></div>
<div><dt>Object</dt><dd>{selected.subject.label ?? selected.subject.identity ?? 'Not stated'}</dd></div>
<div><dt>Object ID</dt><dd>{selected.subject.identity ?? 'Not stated'}</dd></div>
<div><dt>Observed at</dt><dd>{selected.observedAt ?? 'Not stated by source'}</dd></div>
<div><dt>Scan run</dt><dd>{selected.scanRunId}</dd></div>
<div><dt>Import file</dt><dd>{selected.importFileId}</dd></div>
<div><dt>Source location</dt><dd>{textValue(selected.source)}</dd></div>
<div><dt>Collection state</dt><dd>{selected.state}</dd></div>
</dl>
<h4>Original value</h4><RichTextValue value={textValue(selected.originalValue)} resizableTables />
<details><summary>Source metadata and normalized value</summary><pre>{textValue({
normalizedValue: selected.normalizedValue, subject: selected.subject, mappingVersion: selected.mappingVersion, attributes: selected.attributes
})}</pre></details>
</section>}
<section aria-label="Client Information source sections">
<h3>Original source sections</h3>
<EnvironmentEvidenceTables key={`${queryKey}:${request}`} snapshot={result} group="all" />
{result.totalSections > result.sectionLimit && <nav className="client-information-pagination" aria-label="Client Information section pages">
<button className="btn" disabled={result.sectionOffset === 0} onClick={() => setQuery({ ...query, sectionOffset: Math.max(0, result.sectionOffset - result.sectionLimit) })}>Previous sections</button>
<span>{result.sectionOffset + 1}–{Math.min(result.sectionOffset + result.sectionLimit, result.totalSections)} / {result.totalSections}</span>
<button className="btn" disabled={result.sectionOffset + result.sectionLimit >= result.totalSections} onClick={() => setQuery({ ...query, sectionOffset: result.sectionOffset + result.sectionLimit })}>Next sections</button>
</nav>}
</section>
<p className="client-information-provenance">Retrieved {result.generatedAt} · Repository revision {result.repositoryRevision} · {result.scopeMode === 'including-history' ? 'Including historical imports' : 'Current imports'}</p>
</>}
</div>;
}

View File

@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { type ClientInformationArea } from '../../domain/client-information';
import { cloudInformationFacts, cloudInformationOverview } from '../../domain/cloud-information';
import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../../domain/environment-context';
import { ClientInformationBrowser } from './ClientInformationBrowser';
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
const areas: Array<{ id: ClientInformationArea; label: string }> = [
{ id: 'cloud-access', label: 'Authentication & CA' },
{ id: 'cloud-roles', label: 'Role assignments' },
{ id: 'cloud-apps', label: 'App consent' },
{ id: 'cloud-sharing', label: 'External access' }
];
/** Concrete reported facts only; never derives configuration from test outcomes. */
export function CloudInformationTile({ snapshot, scope, icon }: {
snapshot: EnvironmentSnapshot | null | undefined; scope?: EnvironmentSnapshotQuery; icon?: ReactNode;
}) {
const cloudFacts = cloudInformationFacts(snapshot);
const facts = areas.map(area => {
const values = cloudInformationOverview(cloudFacts, area.id);
return { ...area, values: values.length ? values : ['Not reported'] };
});
return <section className="client-env-group" aria-label="Cloud Information">
<header className="client-env-group-header">
<h3 className="client-env-group-title">{icon}<span>Cloud Information</span></h3>
<EnvironmentResultsButton title="Cloud Information">
{scope ? <ClientInformationBrowser scope={scope} areas={areas.map(area => area.id)} />
: <EnvironmentEvidenceTables snapshot={undefined} group="all" />}
</EnvironmentResultsButton>
</header>
<dl className="client-env-facts">{facts.map(fact => <div key={fact.id}>
<dt>{fact.label}</dt><dd>{fact.values.map(value => <div key={value}>{value}</div>)}</dd>
</div>)}</dl>
</section>;
}

View File

@ -1,4 +1,10 @@
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication';
import { managementFindingDistribution, managementWorkspaceDeviationKeys } from '../../reporting/management-business-impact';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { hasSelectedTimeslotScope } from '../../app/work-scope';
@ -6,8 +12,10 @@ import { downloadText } from '../../exports/text-download';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { OperatingSystemInventory } from './OperatingSystemInventory';
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { CloudInformationTile } from './CloudInformationTile';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import type { EnvironmentEvidenceGroup } from './environment-result-evidence';
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
@ -251,8 +259,8 @@ function EnvironmentOverview({ projectId, snapshot, failed }: { projectId?: stri
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
</div>
{!snapshot && !failed && <p className="dashboard-empty">Loading scanner context…</p>}
{failed && <p className="dashboard-empty">Scanner context could not be loaded.</p>}
{!snapshot && !failed && <p className="dashboard-empty">Loading Client Information…</p>}
{failed && <p className="dashboard-empty">Client Information could not be loaded.</p>}
{snapshot && snapshot.sections.length === 0 && <p className="dashboard-empty">This import predates structured scanner-context capture.</p>}
{snapshot && (
<div className="dashboard-coverage-summary" aria-label="Directory coverage summary">
@ -1006,8 +1014,8 @@ function dashboardEnvironmentDomainCount(snapshot: EnvironmentSnapshot | undefin
* zaehlte jede sonstige GUID mit. Die Verarbeitungsliste nennt die Zahl
* dagegen selbst.
*/
function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: number } {
const [inventory, setInventory] = useState<{ gpoCount?: number; snapshots?: number }>({});
function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] } {
const [inventory, setInventory] = useState<{ gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] }>({});
useEffect(() => {
let active = true;
@ -1018,7 +1026,7 @@ function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: n
if (!active) return;
const processed = response.records.filter(record => record.processingStatus);
const gpoCount = processed.reduce((sum, record) => sum + record.gpoCount, 0);
setInventory(processed.length ? { gpoCount, snapshots: processed.length } : {});
setInventory(processed.length ? { gpoCount, snapshots: processed.length, records: processed } : {});
})
.catch(() => { if (active) setInventory({}); });
return () => { active = false; };
@ -1141,8 +1149,24 @@ export function DashboardOverallRing({ score, onClick }: { score?: number; onCli
: <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-data-fact"><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></article>;
export function DashboardTopFact({ value, label, glyph, snapshot, evidenceGroup, backupRecords, fallback }: {
value?: number | string; label: string; glyph: DashboardGlyphName; snapshot?: EnvironmentSnapshot;
evidenceGroup: EnvironmentEvidenceGroup; backupRecords?: GpoBackupStatusRecord[]; fallback?: string;
}) {
const evidence = environmentEvidenceForGroup(snapshot, evidenceGroup);
return <EnvironmentResultsButton title={label} triggerClassName="client-data-fact"
trigger={<><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></>}>
<DashboardFactList items={[{ label, value: value === undefined ? UNKNOWN_FACT : String(value) }]}/>
{fallback && <p className="client-env-evidence-empty">{fallback}</p>}
{!!evidence.observations.length && <table className="client-data-fact-evidence"><thead><tr><th>Field</th><th>Original value</th><th>Source</th><th>Scan run</th></tr></thead>
<tbody>{evidence.observations.map(item => <tr key={item.id}><td>{item.label}</td><td>{typeof item.originalValue === 'string' ? item.originalValue : JSON.stringify(item.originalValue)}</td><td>{item.tool.name}</td><td>{item.scanRunId}</td></tr>)}</tbody>
</table>}
{!!backupRecords?.length && <><p className="client-env-evidence-empty">All processed client backups · the overview count includes every backup listed below.</p>
<table className="client-data-fact-evidence"><thead><tr><th>Backup file</th><th>GPOs</th><th>Processing status</th><th>Updated</th></tr></thead>
<tbody>{backupRecords.map(record => <tr key={record.artifactId}><td>{record.fileName}</td><td>{record.gpoCount}</td><td>{record.processingStatus}</td><td>{record.updatedAt}</td></tr>)}</tbody>
</table></>}
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton>;
}
function dashboardDomainAmount(domain: DashboardDomainScore): number {
@ -1167,6 +1191,28 @@ function DashboardDomainRow({ domain, distributionTotal, onClick }: { domain: Da
return <li>{onClick ? <button type="button" onClick={onClick}>{content}</button> : <div>{content}</div>}</li>;
}
const impactLabels = Object.fromEntries(businessImpactControls.map(theme => [theme.id, theme.label]));
export function DashboardBusinessImpact({ distribution, loading, onSelect }: {
distribution?: ReturnType<typeof managementFindingDistribution>; loading: boolean; onSelect?: (themeId: string) => void;
}) {
const titleId = useId();
const max = Math.max(1, ...(distribution?.themes.map(theme => theme.count) ?? []));
return <section className="client-business-impact" aria-labelledby={titleId}>
<h3 id={titleId}>Possible Business Impact</h3>
{distribution ? <div className="client-business-impact-grid" role="list">
{distribution.themes.map(theme => <div role="listitem" key={theme.id} data-theme={theme.id}>
<button type="button" className="client-business-impact-column" disabled={!onSelect || theme.count === 0}
onClick={() => onSelect?.(theme.id)} aria-label={`${impactLabels[theme.id]}: ${theme.count}`}>
<strong>{theme.count.toLocaleString('en-US')}</strong>
<span className="client-business-impact-track" aria-hidden="true"><i style={shareStyle(share(theme.count, max))} /></span>
<span className="client-business-impact-label">{impactLabels[theme.id]}</span>
</button>
</div>)}
</div> : <p role="status">{loading ? 'Loading Business Impact…' : 'Business Impact is unavailable for this assessment.'}</p>}
</section>;
}
export interface DashboardNativeMetrics {
pingCastleRisk?: number;
purpleKnightPosture?: number;
@ -1313,7 +1359,7 @@ export function dashboardAnalysis(
const state = dashboardResultState(record, activeProfile);
states.set(record, state);
});
const domainBuckets = dashboardDomainAssignment(records);
const domainBuckets = dashboardDomainAssignment(records, activeSheet?.toolId);
const scoreOf = (subset: FindingRecord[]) =>
dashboardScoreFromStates(subset.map(record => states.get(record) ?? 'unknown'), subset);
@ -1338,22 +1384,13 @@ export function dashboardAnalysis(
...score
};
});
const unclassifiedRecords = domainBuckets.get('unclassified') ?? NO_RECORDS;
const unclassifiedScore = scoreOf(unclassifiedRecords);
const unclassifiedDomain: DashboardDomainScore | undefined = unclassifiedRecords.length ? {
id: 'unclassified',
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(records.map(record => states.get(record) ?? 'unknown'), records);
const gapRecordIds = records.filter(record => !['passed', 'deviation', 'not-applicable'].includes(states.get(record) ?? 'unknown'))
.map(record => record.id);
return {
total: records.length,
records,
statusFacet,
severityFacet,
sourceFacet,
@ -1362,7 +1399,6 @@ export function dashboardAnalysis(
statusCount: (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0,
sourceScores,
domains,
unclassifiedDomain,
totalResult,
gapRecordIds,
overallScore: dashboardOverallScore(sourceScores)
@ -1378,6 +1414,12 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
const engines = useEnginePreferences();
const impactContext = useAssessmentContext(Boolean(dataset) && engines.report, app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
const impactDistribution = useMemo(() => {
if (impactContext.status !== 'ready') return undefined;
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
return managementFindingDistribution(publishContextReport(impactContext.context), analysis.totalResult.deviations, keys);
}, [impactContext.status === 'ready' ? impactContext.context : undefined, analysis, activeProfile]);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
@ -1386,6 +1428,10 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const exportReport = async (kind: 'management' | 'technical') => {
const projectId = app.targetProjectId;
if (!readEnginePreferences().report || !projectId || !dataset || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)) return;
if (kind === 'management' && !readEnginePreferences().context) {
setReportError('Management Report requires Context Engine. Enable it in Engines and try again.');
return;
}
const requestedScope = reportScopeKey;
setReportExporting(kind);
setReportError(null);
@ -1394,7 +1440,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const run = await assessorApi.createReportRun(projectId, {
kind, scanRunIds: app.activeScanRunIds, assessmentIds: app.activeAssessmentIds
});
if (!readEnginePreferences().report || reportScopeRef.current !== requestedScope) return;
if (!readEnginePreferences().report || (kind === 'management' && !readEnginePreferences().context) || reportScopeRef.current !== requestedScope) return;
downloadText(browserDownloadAdapter, run.fileName, run.html, 'text/html;charset=utf-8');
} catch (reason) {
if (readEnginePreferences().report) setReportError(reason instanceof Error ? reason.message : 'Report could not be generated.');
@ -1419,7 +1465,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const {
total, statusFacet, severityFacet, sourceFacet, tools, reports,
sourceScores, domains, unclassifiedDomain, totalResult, gapRecordIds, overallScore
sourceScores, domains, totalResult, gapRecordIds, overallScore
} = analysis;
const deviations = totalResult.deviations;
const assessmentCoverage = totalResult.coverage;
@ -1430,7 +1476,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
// Give repeated overview facts one home; Results retains the full category.
// Compare only the same named fact, never unrelated facts with equal values.
const directoryDetailLabels = ['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID'];
const countOverview = new Map([
['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))],
['Domain controllers', numberFact(domainControllers)],
@ -1438,9 +1483,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
['Computer objects', numberFact(computerAccounts)]
]);
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
const domainOverview = domainInformation.filter(item => !directoryDetailLabels.includes(item.label) && !alreadyInData(item));
const directoryOverview = environmentDetail.directory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
const domainOverview = domainInformation.filter(item => !alreadyInData(item));
const inventoryOverview = environmentDetail.inventory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
@ -1468,7 +1511,7 @@ 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 displayedDomains = domains;
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore);
@ -1507,10 +1550,10 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
}}><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>
<div className="client-report-actions" aria-label="Assessment reports">
<button className="btn" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
{reportExporting === 'management' ? 'Creating Management Report…' : 'Management Report'}
</button>
<button className="btn" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('technical')}>
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('technical')}>
{reportExporting === 'technical' ? 'Creating Technical Report…' : 'Technical Report'}
</button>
</div>
@ -1547,6 +1590,12 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setStatusFilter('all');
setPathFindingIds(domain.reviewRecords.map(record => record.id));
}) : undefined} />)}</ul>
{engines.report && engines.context && <DashboardBusinessImpact distribution={impactDistribution} loading={impactContext.status === 'loading'}
onSelect={themeId => jump(() => {
const contextFilter = app.facetFilters[CONTEXT_CLUSTER_FACET_ID];
if (contextFilter) setFacetFilter(CONTEXT_CLUSTER_FACET_ID, contextFilter);
setFacetFilter(BUSINESS_IMPACT_FACET_ID, { values: [themeId] });
})} />}
</section>
</section>
@ -1566,30 +1615,48 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
})}><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Gaps</span></button>
</div>
<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" />
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" snapshot={environment.snapshot} evidenceGroup="ad-domains" />
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" snapshot={environment.snapshot} evidenceGroup="entra-tenants" fallback={!dashboardEntraTenantCount(environment.snapshot) && !dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') && maesterSource?.records.length ? "Tenant presence inferred from loaded Maester results; no retained tenant identity is available." : undefined} />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" snapshot={environment.snapshot} evidenceGroup="user-accounts" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" snapshot={environment.snapshot} evidenceGroup="computer-objects" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" snapshot={environment.snapshot} evidenceGroup="domain-controllers" />
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={gpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
</section>
</section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<header className="client-env-group-header">
<h2 id="client-domain-information-heading">Domain Information</h2>
<EnvironmentResultsButton title="Domain Information">
<DashboardFactList items={domainInformation}/>
<EnvironmentEvidenceTables snapshot={environment.snapshot} group="directory"/>
</EnvironmentResultsButton>
</header>
<dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section>
</aside>
</div>
<ClientContextHeatmap onOpenCluster={clusterId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview">
<DashboardFactGroup title="Directory" items={environmentDetail.directory} overviewItems={directoryOverview} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />
<CloudInformationTile snapshot={environment.snapshot} icon={<DashboardGlyph name="cloud"/>}
key={`cloud-information:${app.targetProjectId}:${app.activeAssessmentIds?.join(',')}:${app.activeScanRunIds?.join(',')}`}
scope={app.targetProjectId ? { projectId: app.targetProjectId, assessmentId: app.activeAssessmentIds?.length === 1 ? app.activeAssessmentIds[0] : undefined,
scanRunIds: app.activeScanRunIds } : undefined} />
</div>
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />

View File

@ -287,7 +287,7 @@ function EvidenceSection({ section, snapshot, linkedSections }: {
export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) {
const [visibleSections, setVisibleSections] = useState(PAGE_SIZE);
if (!snapshot) return <p className="client-env-evidence-empty">No retained scanner context available for this selection.</p>;
if (!snapshot) return <p className="client-env-evidence-empty">No retained Client Information available for this selection.</p>;
const { sections } = environmentEvidenceForGroup(snapshot, group);
const linkedSections = new Map<string, ScannerReportSection>();
for (const section of sections) {

View File

@ -2,8 +2,9 @@ import { useRef, useState, type ReactNode } from 'react';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
/** One interaction for every Environment Detail tile, including OS inventory. */
export function EnvironmentResultsButton({ title, children }: {
export function EnvironmentResultsButton({ title, children, trigger, triggerClassName }: {
title: string; children: ReactNode | ((dismiss: () => void) => ReactNode);
trigger?: ReactNode; triggerClassName?: string;
}) {
const [windowOpen, setWindowOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
@ -12,9 +13,9 @@ export function EnvironmentResultsButton({ title, children }: {
requestAnimationFrame(() => triggerRef.current?.focus());
};
return <>
<button ref={triggerRef} className={`btn results-btn client-environment-results-button${windowOpen ? ' is-open' : ''}`}
<button ref={triggerRef} className={`${triggerClassName ?? 'btn results-btn client-environment-results-button'}${windowOpen ? ' is-open' : ''}`}
type="button" onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen}
aria-label={`Open ${title} results`} title="Open results">Results</button>
aria-label={`Open ${title} results`} title="Open results">{trigger ?? 'Results'}</button>
{windowOpen && <FloatingDataWindow title={title} onClose={closeWindow}>
{typeof children === 'function' ? children(() => setWindowOpen(false)) : children}
</FloatingDataWindow>}

View File

@ -38,6 +38,8 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length))
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
const inventoryFacts = summary.length || systems.length ? [...summary, ...systems] : [{ label: 'Inventory', value: '—' }];
const facts = inventoryFacts;
return <section className="client-os-inventory" aria-label="Operating systems">
<header>
<h3>{icon}<span>Operating systems</span></h3>
@ -47,9 +49,9 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
</EnvironmentResultsButton>
</header>
<dl className="client-env-facts">
{summary.length || systems.length ? [...summary, ...systems].slice(0, 4).map((item, index) => <div key={`${item.label}:${index}`}>
{facts.map((item, index) => <div key={`${item.label}:${index}`}>
<dt>{item.label}</dt><dd>{item.value}</dd>
</div>) : <div><dt>Inventory</dt><dd>—</dd></div>}
</div>)}
</dl>
</section>;
}

View File

@ -1,7 +1,9 @@
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { isOperatingSystemEolLabel, isOperatingSystemEolObservation } from './operating-system-inventory';
export type EnvironmentEvidenceGroup = 'all' | 'directory' | 'resilience' | 'inventory'
| 'password-policy' | 'certificates' | 'source-metrics' | 'operating-systems';
| 'password-policy' | 'certificates' | 'source-metrics' | 'operating-systems'
| 'ad-domains' | 'entra-tenants' | 'user-accounts' | 'computer-objects' | 'domain-controllers' | 'gpos';
export interface EnvironmentEvidence {
sections: ScannerReportSection[];
@ -124,14 +126,21 @@ export function environmentSectionTitle(snapshot: EnvironmentSnapshot, section:
return environmentNativeHeading(section.label) || environmentNativeHeading(section.sectionKey);
}
const osTable = (table: ScannerReportTable) => /operating\s+systems?/i.test(table.name);
const osTable = (table: ScannerReportTable) => /operating\s+systems?/i.test(table.name)
|| table.headers.some(isOperatingSystemEolLabel);
const resilienceProperty = /^(?:backup\.|laps\.|kerberos\.krbtgt\.|logging\.wef\.|domain\.recycle_bin_enabled$)/i;
const inventorySection = /^section(?:UserInformation|ComputerInformation|AdminGroups|Trustsdetails|ControlPathsAnalysis|neverexpiresuser|enabledtrusteddelegationcomputer)$/i;
function observationInGroup(observation: EnvironmentObservation, group: EnvironmentEvidenceGroup): boolean {
const property = observation.property;
const section = observation.sectionKey ?? '';
if (group === 'operating-systems') return /^operating_system\./i.test(property)
if (group === 'ad-domains') return /^(?:domain\.|forest\.)/i.test(property) || /^sectionDomainInformation$/i.test(section);
if (group === 'entra-tenants') return /^entra\./i.test(property);
if (group === 'user-accounts') return /^user\./i.test(property) || /^sectionUserInformation$/i.test(section);
if (group === 'computer-objects') return /^computer\./i.test(property) || /^sectionComputerInformation$/i.test(section);
if (group === 'domain-controllers') return /^domain\.controller/i.test(property) || /^sectionDomainControllers$/i.test(section);
if (group === 'gpos') return /^gpo\./i.test(property) || /^sectionGPO$/i.test(section);
if (group === 'operating-systems') return isOperatingSystemEolObservation(observation) || /^operating_system\./i.test(property)
|| /operating\s+systems?/i.test(String(observation.attributes.tableName ?? ''));
if (group === 'resilience') return resilienceProperty.test(property);
if (group === 'password-policy') return /^password_policy\./i.test(property) || /^sectionPasswordPolicies$/i.test(section);
@ -148,6 +157,12 @@ function observationInGroup(observation: EnvironmentObservation, group: Environm
function sectionForGroup(section: ScannerReportSection, group: EnvironmentEvidenceGroup): ScannerReportSection | undefined {
if (group === 'all') return section;
const key = section.sectionKey;
if (group === 'ad-domains' && /^sectionDomainInformation$/i.test(key)) return section;
if (group === 'entra-tenants' && /^(?:entra|azure)[-_ ]|^section(?:Entra|Azure)/i.test(key)) return section;
if (group === 'user-accounts' && /^sectionUserInformation$/i.test(key)) return section;
if (group === 'computer-objects' && /^sectionComputerInformation$/i.test(key)) return section;
if (group === 'domain-controllers' && /^sectionDomainControllers$/i.test(key)) return section;
if (group === 'gpos' && /^sectionGPO$/i.test(key)) return section;
if (group === 'directory' && /^section(?:DomainInformation|Infrastructure)$/i.test(key)) return section;
if (group === 'inventory' && inventorySection.test(key)) return section;
if (group === 'password-policy' && /^sectionPasswordPolicies$/i.test(key)) return section;

View File

@ -23,6 +23,24 @@ function count(value: string): number | undefined {
return Number.isSafeInteger(number) && number >= 0 ? number : undefined;
}
/** Explicit native PingCastle totals only; never derive lifecycle from product names. */
export const isOperatingSystemEolLabel = (label: string): boolean =>
/^(?:(?:nb|number of)\s+)?(?:eol os|os eol|end[- ]of[- ]life (?:os|operating systems?))$/i.test(label.trim());
export function isOperatingSystemEolObservation(item: EnvironmentObservation): boolean {
return item.tool.id === 'pingcastle' && /^sectionComputerInformation$/i.test(item.sectionKey ?? '')
&& isOperatingSystemEolLabel(item.label);
}
export function operatingSystemEolCount(snapshot?: EnvironmentSnapshot): number | undefined {
const items = (snapshot?.observations ?? []).filter(isOperatingSystemEolObservation);
// A single retained total is authoritative. Multiple reports/rows are not additive.
const unique = [...new Map(items.map(item => [JSON.stringify([item.scanRunId, item.importFileId,
item.sectionKey, item.attributes.sourceTableIndex, item.source.rowIndex, item.source.columnIndex, item.source.objectPath,
item.originalValue, item.normalizedValue, item.state]), item])).values()];
return unique.length === 1 && unique[0].state === 'observed' ? count(value(unique[0])) : 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;

View File

@ -1,91 +1,14 @@
import type { FindingRecord } from '../../domain/finding';
import type { UnifiedResultProjection } from '../../domain/unified-result';
import { securityDomainBuckets } from '../../evaluation/security-domains';
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|\bldaps?\b|channel\s*binding|anonymous\s*binding|rootdse|tls\s*1\.[01]/i },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema|organizational\s*units?|\bous?\b.*accidental\s*deletion|\bntfrs\b|\bsysvol\b/i },
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|hardened\s*paths?|firewall|print\s*spooler|\bspooler\b|attack\s*surface|\basr\b|defender|operating\s*system|obsolete\s+os|unsupported\s+(?:os|operating\s*system)|script\s*engines?|\bcve-\d+|lock\s*screen|\bsmb\s*v?1\b|(?:ip\s*)?source\s*routing|icmp\s*redirect|keep\s*alive|netbios\s*name\s*release|router\s*discovery|tcp\s*(?:max\s*data\s*)?retransmissions?|kernel\s*dma\s*protection|nonamereleaseondemand|performrouterdiscovery|tcpmaxdataretransmissions|disableipsourcerouting|enableicmpredirect|keepalivetime/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 MAESTER_CLOUD_SUITE = /(?:^|[\s,;|\["'])(?:orca(?:\.|\b)|exo(?=$|[\s,;|\]"'])|eidsca(?:\.|\b)|cisa\.ms\.(?:exo|aad)(?:\.|\b)|maester\/(?:exchange|teams|entra)(?=$|[\s,;|\]"']))/i;
const recordDomainEvidenceCache = new WeakMap<FindingRecord, {
text: string;
narrativeText: string;
explicitGpo: boolean;
scannerSuiteText: string;
}>();
function recordDomainEvidence(record: FindingRecord): {
text: string;
narrativeText: string;
explicitGpo: boolean;
scannerSuiteText: string;
} {
let cached = recordDomainEvidenceCache.get(record);
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|source rule id)$/i.test(field.label.trim()))
.map(field => field.value)].join(' '),
narrativeText: fields
.filter(field => /^(?:technical explanation|rationale|recommendation|remediation(?: guidance| recommendation)?|advised solution|solution|impact|details|test details)$/i.test(field.label.trim()))
.map(field => field.value).join(' '),
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim())),
// Maester exposes stable suite/control identifiers in BLOCK, ID and TAG.
// They are fallback evidence only: descriptive security-domain matches
// above retain priority and the source values remain unchanged.
scannerSuiteText: [record.title, ...fields
.filter(field => /^(?:block|id|tag|tags|category|categories)$/i.test(field.label.trim()))
.map(field => field.value)].join(' ')
};
recordDomainEvidenceCache.set(record, cached);
}
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';
const descriptiveMatch = securityDomainCatalog.find(domain => domain.pattern.test(evidence.text));
if (descriptiveMatch) return descriptiveMatch.id;
// Broader narrative fields are useful fallback evidence, but they may mention
// several domains in one remediation. Assign only when the evidence is unique;
// otherwise keeping Unclassified is more honest than inventing a category.
const narrativeMatches = securityDomainCatalog.filter(domain => domain.pattern.test(evidence.narrativeText));
if (narrativeMatches.length === 1) return narrativeMatches[0].id;
if (MAESTER_CLOUD_SUITE.test(evidence.scannerSuiteText)) return 'cloud';
return unclassifiedSecurityDomain.id;
}
/** Unified-result counterpart of the Work/Client Info catalog assignment. */
export function securityDomainIdForUnifiedResult(result: UnifiedResultProjection): string {
if (result.sourceKind === 'policy-evaluation' || result.sourceKind === 'policy-observation'
|| result.categories.some(category => /^gpos?$/i.test(category.categoryLabel.trim()))) return 'gpo';
const evidence = [result.title, result.description,
...result.categories.map(category => category.categoryLabel)].filter(Boolean).join(' ');
return securityDomainCatalog.find(domain => domain.pattern.test(evidence))?.id ?? unclassifiedSecurityDomain.id;
}
// The classifier lives in Evaluation (user decision 2026-10-04); views only consume it.
export {
SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord
} from '../../evaluation/security-domains';
/** 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 dashboardDomainAssignment(records: FindingRecord[], toolId?: string): Map<string, FindingRecord[]> {
return securityDomainBuckets(records, toolId);
}
export function dashboardDomainRecords(records: FindingRecord[], domainId: string): FindingRecord[] {

View File

@ -2,6 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
import { createPortal } from 'react-dom';
import { previousPolicyReviews } from '../review-workspace/database-review';
import { useApp } from '../../app/AppProvider';
import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState';
import { searchAllFindings } from '../search-filter/search-all-findings';
import { TriangleIcon } from '../../components/TriangleIcon';
import { DataTable } from '../../components/DataTable/DataTable';
import { FindingCard } from '../../components/FindingCard/FindingCard';
@ -332,19 +334,10 @@ export function DatasetViewer() {
<>
<span ref={parkingAnchorRef} className="results-parking-anchor" aria-hidden="true" />
{!records.length ? (
<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>
<FilterEmptyState className="work-filter-empty" title="No results match the current filters"
onReset={app.resetFilters} onSearchAll={() => searchAllFindings(app)}>
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.
</FilterEmptyState>
) : 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} />}

View File

@ -0,0 +1,144 @@
import { useEffect, useId, useState, type RefObject } from 'react';
// Data delivery, not foreign keys or a dependency inferred from tile order.
export const DATA_FLOW_RELATIONSHIPS = [
['foundation', 'upload', 'Assessment scope', 'direct'],
['upload', 'scanner-processing', 'Scanner reports', 'direct'],
['upload', 'gpo-inventory', 'GPO backups', 'direct'],
['scanner-processing', 'scanner-mapping', 'Extracted rows', 'direct'],
['scanner-mapping', 'scanner-canonical', 'Mapped data', 'direct'],
['scanner-canonical', 'scanner-findings', 'Normalized results', 'direct'],
['gpo-inventory', 'gpo-identity', 'Parsed settings', 'direct'],
['gpo-identity', 'gpo-aggregation', 'Policy identities', 'direct'],
['gpo-aggregation', 'gpo-evaluation', 'Snapshot states', 'direct'],
['reference-baselines', 'gpo-evaluation', 'Baseline variants', 'reference'],
['gpo-evaluation', 'gpo-findings', 'Deviations', 'direct'],
['scanner-findings', 'result-materialization', 'Scanner results', 'direct'],
['gpo-findings', 'result-materialization', 'Policy results', 'direct'],
['result-materialization', 'result-db', 'Disclosed rows', 'direct'],
['scanner-findings', 'review', 'Scanner findings', 'work-left'],
['gpo-findings', 'review', 'Policy findings', 'work-right'],
['scanner-findings', 'comparison', 'Scanner history', 'history-left'],
] as const;
type Edge = { id: string; from: string; to: string; label: string; d: string; x: number; y: number; rotation: number; active: boolean };
type Point = { x: number; y: number };
type Box = { left: number; right: number; top: number; bottom: number };
// Account for the stroke and marker tip, including when tiles are stacked.
const PORT_CLEARANCE = 4;
function clearPath(points: Point[], boxes: Box[]) {
return points.slice(1).every((end, index) => {
const start = points[index];
return boxes.every(box => box.right <= box.left || box.bottom <= box.top ||
Math.max(start.x, end.x) <= box.left - 2 || Math.min(start.x, end.x) >= box.right + 2 ||
Math.max(start.y, end.y) <= box.top - 2 || Math.min(start.y, end.y) >= box.bottom + 2);
});
}
function pathData(points: Point[]) {
return points.map((point, index) => index === 0 ? `M${point.x} ${point.y}` :
point.x === points[index - 1].x ? `V${point.y}` : `H${point.x}`).join(' ');
}
export function DataFlowRelationships({ root, ids, selected }: {
root: RefObject<HTMLElement | null>; ids: string[]; selected: string;
}) {
const marker = useId().replace(/:/g, '');
const [layout, setLayout] = useState<{ width: number; height: number; edges: Edge[] }>({ width: 0, height: 0, edges: [] });
const idKey = ids.join(',');
useEffect(() => {
const element = root.current;
if (!element) return;
const nodes = [...element.querySelectorAll<HTMLElement>('[data-flow-id]')];
const measure = () => {
const bounds = element.getBoundingClientRect();
const boxes = new Map(nodes.map(node => {
const box = node.getBoundingClientRect();
return [node.dataset.flowId!, { left: box.left - bounds.left, right: box.right - bounds.left,
top: box.top - bounds.top, bottom: box.bottom - bounds.top, x: box.left - bounds.left + box.width / 2,
y: box.top - bounds.top + box.height / 2, active: node.dataset.selected === 'true' }];
}));
const edges = DATA_FLOW_RELATIONSHIPS.flatMap(([from, to, label, route]) => {
const a = boxes.get(from), b = boxes.get(to);
if (!a || !b) return [];
let points: Point[], x: number, y: number, rotation = 0;
if (route === 'reference' && a.left > b.right) {
const rail = (a.left + b.right) / 2;
points = [{ x: a.left - PORT_CLEARANCE, y: a.y }, { x: rail, y: a.y },
{ x: rail, y: b.y }, { x: b.right + PORT_CLEARANCE, y: b.y }];
x = rail; y = (a.y + b.y) / 2; rotation = -90;
} else if (route.includes('left') || route.includes('right') || b.top < a.bottom) {
const left = route.includes('left');
const rail = left ? -16 : bounds.width + 16;
const start = left ? a.left - PORT_CLEARANCE : a.right + PORT_CLEARANCE;
y = b.top - (route.startsWith('history') ? 32 : route === 'work-left' ? 72 : 32);
points = [{ x: start, y: a.y }, { x: rail, y: a.y }, { x: rail, y },
{ x: b.x, y }, { x: b.x, y: b.top - PORT_CLEARANCE }];
// Put long side-route labels in the outer lanes so they do not
// overlap the central disclosure label at the consumer split.
x = left ? bounds.width * 0.18 : bounds.width * 0.86;
} else {
// Cross-lane merges run below every lane, never through a later
// GPO tile just because the scanner lane happens to be shorter.
y = Math.abs(a.x - b.x) > 1 ? b.top - 32 : (a.bottom + b.top) / 2;
points = [{ x: a.x, y: a.bottom + PORT_CLEARANCE }, { x: a.x, y },
{ x: b.x, y }, { x: b.x, y: b.top - PORT_CLEARANCE }];
x = (a.x + b.x) / 2;
}
const obstacles = [...boxes.values()];
if (!clearPath(points, obstacles)) {
// A direct path can cross intervening tiles in the one-column view.
// Choose a clear side rail from the measured tile geometry instead.
const rails = [...new Set([-16, bounds.width + 16,
...obstacles.flatMap(box => [box.left - 24, box.right + 24])])];
const alternatives = rails.flatMap(rail => {
const left = rail < Math.min(a.left, b.left);
const right = rail > Math.max(a.right, b.right);
if (!left && !right) return [];
const startX = left ? a.left - PORT_CLEARANCE : a.right + PORT_CLEARANCE;
const endX = left ? b.left - PORT_CLEARANCE : b.right + PORT_CLEARANCE;
const path = [{ x: startX, y: a.y }, { x: rail, y: a.y },
{ x: rail, y: b.y }, { x: endX, y: b.y }];
return clearPath(path, obstacles) ? [{ rail, endX, path,
length: Math.abs(startX - rail) + Math.abs(b.y - a.y) + Math.abs(endX - rail) }] : [];
}).sort((one, two) => one.length - two.length);
const bypass = alternatives[0];
if (bypass) {
points = bypass.path;
if (Math.abs(bypass.endX - bypass.rail) >= label.length * 7 + 20) {
x = (bypass.endX + bypass.rail) / 2; y = b.y; rotation = 0;
} else {
x = bypass.rail; y = (a.y + b.y) / 2; rotation = -90;
}
}
}
return [{ id: `${from}-${to}`, from, to, label, d: pathData(points), x, y, rotation, active: a.active || b.active }];
});
const next = { width: bounds.width, height: bounds.height, edges };
setLayout(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
};
measure();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
observer?.observe(element);
nodes.forEach(node => observer?.observe(node));
window.addEventListener('resize', measure);
return () => { observer?.disconnect(); window.removeEventListener('resize', measure); };
}, [root, idKey, selected]);
return <svg className="db-engine-flow__edges db-flow__edges" width={layout.width} height={layout.height}
role="img" aria-label="Implemented process data relationships">
<defs><marker id={marker} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 Z" />
</marker><marker id={`${marker}-selected`} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path className="is-related" d="M0 0 L10 5 L0 10 Z" />
</marker></defs>
{layout.edges.map(edge => <path key={edge.id} data-relationship={edge.id} d={edge.d}
markerEnd={`url(#${edge.active ? `${marker}-selected` : marker})`} className={edge.active ? 'is-related' : ''}>
<title>{edge.from} → {edge.to}: {edge.label}</title>
</path>)}
{layout.edges.map(edge => <g className={`db-flow__edge-label${edge.active ? ' is-related' : ''}`} key={`label-${edge.id}`}
transform={edge.rotation ? `rotate(${edge.rotation} ${edge.x} ${edge.y})` : undefined}>
<rect x={edge.x - (edge.label.length * 7 + 12) / 2} y={edge.y - 12} width={edge.label.length * 7 + 12} height="24" />
<text x={edge.x} y={edge.y} textAnchor="middle" dominantBaseline="central">{edge.label}</text>
</g>)}
</svg>;
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useState } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { DbStructureResponse, DbStructureTable } from '../../domain/db-structure';
import { TriangleIcon } from '../../components/TriangleIcon';
@ -6,6 +6,7 @@ import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadi
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
import { EngineDataFlow } from './EngineDataFlow';
import { DataFlowRelationships } from './DataFlowRelationships';
import { buildDbStructureFlow, buildDbStructureViews, dbStructureNeighbours, dbStructureShortName, type DbFlowStep } from './db-structure-flow';
const assessorApi = new AssessorApiClient();
@ -87,14 +88,20 @@ function DbStructureTableDetails({ table }: { table: DbStructureTable }) {
</div>;
}
function FlowStep({ step, number, selected, related, onSelect, continued, note, references = false }: {
function FlowStep({ step, number, selected, selectedStep, onSelectStep, related, onSelect, continued, note, references = false }: {
step: DbFlowStep; number?: number; selected: string; related: Map<string, 'outgoing' | 'incoming'>;
onSelect: (name: string) => void; continued: boolean; note?: string;
selectedStep: string; onSelectStep: (id: string) => void;
/** Read-view chips only jump to a table; selection and relation marks stay on the owning step. */
references?: boolean;
}) {
return <article className={`db-flow__step is-${step.lane}${continued ? ' is-continued' : ''}`} aria-label={step.title}>
<header>{number !== undefined && <span className="db-flow__number">{number}</span>}<h4>{step.title}</h4></header>
const active = selectedStep === step.id || (!references && step.resolved.some(table => table.name === selected));
const toggle = () => onSelectStep(selectedStep === step.id ? '' : step.id);
return <article className={`db-flow__step is-${step.lane}${continued ? ' is-continued' : ''}`} data-flow-id={step.id} data-selected={active} aria-label={step.title}
onClick={event => { if (!(event.target as HTMLElement).closest('button')) toggle(); }}>
<header><h4><button type="button" className="db-flow__select" aria-pressed={active} onClick={toggle}>
{number !== undefined && <span className="db-flow__number" aria-hidden="true">{number}</span>}{step.title}
</button></h4></header>
<p>{step.purpose}</p>
{note && <p className="db-flow__note">{note}</p>}
<ul className="db-flow__tables">{step.resolved.map(table => <li key={table.name}>
@ -113,6 +120,8 @@ function FlowStep({ step, number, selected, related, onSelect, continued, note,
/** Program-flow view of the schema: from upload to Work, with the two processing paths side by side. */
function DbStructureFlow({ tables }: { tables: DbStructureTable[] }) {
const [selected, setSelected] = useState('');
const [selectedStep, setSelectedStep] = useState('');
const flowRoot = useRef<HTMLElement>(null);
const { steps, other } = buildDbStructureFlow(tables);
const views = buildDbStructureViews(tables);
const neighbours = selected ? dbStructureNeighbours(tables, selected) : { outgoing: [], incoming: [] };
@ -122,13 +131,14 @@ function DbStructureFlow({ tables }: { tables: DbStructureTable[] }) {
]);
const step = (id: string) => steps.find(item => item.id === id);
const lane = (name: DbFlowStep['lane']) => steps.filter(item => item.lane === name);
const props = { selected, related, onSelect: setSelected };
const props = { selected, selectedStep, onSelectStep: setSelectedStep, related, onSelect: setSelected };
const selectedTable = tables.find(table => table.name === selected);
const shared = (id: string, continued: boolean) => {
const item = step(id);
return item ? <FlowStep step={item} {...props} continued={continued} /> : null;
};
return <section className="db-flow" aria-label="Data flow">
return <section className="db-flow" aria-label="Data flow" ref={flowRoot}>
<DataFlowRelationships root={flowRoot} ids={[...steps.map(item => item.id), ...views.map(item => item.id)]} selected={`${selected}|${selectedStep}`} />
<header className="db-flow__header">
<h3>Data flow</h3>
</header>

View File

@ -1,13 +1,16 @@
import { useId, useState } from 'react';
/** Presentation-only architecture map; never imports engine implementations. */
/** Presentation-only architecture map; never imports engine implementations.
* Every current/future engine declares engine: true to inherit its persistent neutral frame.
* Only selection highlights connected arrows.
*/
const nodes = [
{ id: 'input', name: 'Source inputs', caption: 'Scanner reports · GPO backups · catalogs', description: 'Original scanner reports, GPO backups and reference catalogs enter the Core import and evaluation pipeline.' },
{ id: 'core', name: 'Core Engine', caption: 'Authoritative results · always active', description: 'All existing ASSESSOR functions: import, normalization, evaluation, scoring, client records and disclosure. Owns canonical PostgreSQL data. Works independently of optional engines.' },
{ id: 'core', engine: true, name: 'Core Engine', caption: 'Authoritative results · always active', description: 'All existing ASSESSOR functions: import, normalization, evaluation, scoring, client records and disclosure. Owns canonical PostgreSQL data. Works independently of optional engines.' },
{ id: 'database', name: 'Canonical PostgreSQL', caption: 'Core-owned data · disclosed Result DB', description: 'Canonical local datastore. The Core owns persistence, including the stored and verified Result DB projection.' },
{ id: 'port', name: 'Core read-only port', caption: 'Scoped results · provenance', description: 'Declared read-only Core port backed by read-only database access. Optional engines read authoritative results without changing stored data.' },
{ id: 'context', name: 'Context Engine', caption: 'Derived context · optional', description: 'Reads findings and GPO verdicts through the read-only Core port and read-only database access. Produces versioned clusters, confidence and evidence. In-memory results; no own persistence or Core writes.' },
{ id: 'report', name: 'Report Engine', caption: 'Read-only reporting · optional', description: 'Reads Core results through the read-only Core port and a separate read-only PostgreSQL pool. Produces report projections, management and technical reports. Own file archive; no Core writes.' },
{ id: 'context', engine: true, name: 'Context Engine', caption: 'Derived context · optional', description: 'Reads findings and GPO verdicts through the read-only Core port and read-only database access. Produces versioned clusters, confidence and evidence. In-memory results; no own persistence or Core writes.' },
{ id: 'report', engine: true, name: 'Report Engine', caption: 'Read-only reporting · optional', description: 'Reads Core results through the read-only Core port and a separate read-only PostgreSQL pool. Produces report projections, management and technical reports. Own file archive; no Core writes.' },
{ id: 'ui', name: 'Core UI & exports', caption: 'Findings · scores · Result DB', description: 'Application views show authoritative Core findings, verdicts and scores. Result DB exports obey the Core disclosure policy.' },
{ id: 'context-ui', name: 'Context in the UI', caption: 'Work · Client Info · Pathfinder', description: 'Published Context results appear through existing UI extension points. They never override technical results or client records.' },
{ id: 'archive', name: 'Report outputs', caption: 'File archive · downloads', description: 'Generated reports are stored in the separate Report Engine file archive and offered for download; no report storage in Core tables.' }
@ -42,7 +45,7 @@ export function EngineDataFlow() {
</svg>
{relationships.map(edge => <span key={`${edge.from}-${edge.to}`} data-edge={`${edge.from}-${edge.to}`} className={`db-engine-flow__edge-label${edge.planned ? ' is-planned' : ''}`}>{edge.label}</span>)}
{nodes.map(node => <button type="button" key={node.id} aria-pressed={selected === node.id}
className={`db-engine-flow__node${['core', 'context', 'report'].includes(node.id) ? ' is-engine' : ''}${related.has(node.id) ? ' is-related' : ''}`}
className={`db-engine-flow__node${node.engine ? ' is-engine' : ''}${related.has(node.id) ? ' is-related' : ''}`}
data-node={node.id} onClick={() => setSelected(node.id === selected ? '' : node.id)}>
<strong>{node.name}</strong><span>{node.caption}</span>
</button>)}

View File

@ -25,7 +25,7 @@ export const DB_FLOW_STEPS: DbFlowStepDefinition[] = [
tables: ['scan_runs', 'import_processing_runs', 'source_tables', 'scanner_report_sections', 'import_diagnostics'] },
{ id: 'scanner-mapping', lane: 'scanner', title: 'Recognition & mapping', purpose: 'Scanner profiles and comparison rules that turn source rows into canonical roles.',
tables: ['profile_registrations', 'local_comparison_rules'] },
{ id: 'scanner-canonical', lane: 'scanner', title: 'Canonical model', purpose: 'Tool-independent assets, their properties and relations, and environment facts.',
{ id: 'scanner-canonical', lane: 'scanner', title: 'Canonical model', purpose: 'Tool-independent assets, their properties and relations, and Client Information. Retained facts and report sections are available through the scoped, read-only Client Information port.',
tables: ['canonical_assets', 'asset_properties', 'asset_edges', 'edge_coverage', 'environment_observations'] },
{ id: 'scanner-findings', lane: 'scanner', title: 'Findings & evidence', purpose: 'Rule definitions, occurrences per run, their categories and linked evidence.',
tables: ['finding_definitions', 'finding_occurrences', 'finding_occurrence_categories', 'result_categories', 'evidence'] },
@ -46,7 +46,7 @@ export const DB_FLOW_STEPS: DbFlowStepDefinition[] = [
tables: ['reviews'] },
{ id: 'comparison', lane: 'shared', title: 'Comparison over time', purpose: 'Timeslot comparisons and the lifecycle of findings between assessments.',
tables: ['comparisons', 'timeslot_evidence_comparisons', 'finding_obligations', 'finding_obligation_transitions', 'finding_obligation_scope_decisions'] },
{ id: 'result-materialization', lane: 'shared', title: 'Result DB disclosure & storage', purpose: 'Core checks and stores the disclosed result projection when source data changes. UI and exports read the same verified rows.',
{ id: 'result-materialization', lane: 'shared', title: 'Result DB · Data protection & disclosure', purpose: 'Core removes or masks sensitive identity data and validates the released result projection before storage. Result DB UI and exports read the same controlled result set.',
tables: ['result_db_rows', 'result_db_state'] }
];

View File

@ -1,4 +1,6 @@
import { createPortal } from 'react-dom';
import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState';
import { searchAllFindings } from '../search-filter/search-all-findings';
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react';
import { useApp } from '../../app/AppProvider';
import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
@ -606,7 +608,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
})}</g>
</g>
</svg>
{!nodes.length && <div className="obsidian-graph-empty"><strong>No matching graph nodes.</strong><span>Change the shared filters or open a scan with MITRE mappings.</span></div>}
{!nodes.length && <div className="obsidian-graph-empty"><FilterEmptyState title="No matching graph nodes" onReset={app.resetFilters} onSearchAll={() => { searchAllFindings(app); onOpenFinding(); }}>Change the shared filters or open a scan with MITRE mappings.</FilterEmptyState></div>}
</section>
</div>

View File

@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
import { Dialog } from '../../components/Dialog/Dialog';
export interface ClientInformationChange { label: 'Name' | 'Consultant'; previous: string; next: string; }
export function ClientInformationChangeDialog({ changes, onCancel, onConfirm }: {
changes: ClientInformationChange[]; onCancel(): void; onConfirm(): void;
}) {
const content = useRef<HTMLDivElement>(null);
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
const dialog = content.current?.closest<HTMLElement>('[role="dialog"]');
cancel.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); onCancel(); }
if (event.key !== 'Tab' || !dialog) return;
const buttons = [...dialog.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
const first = buttons[0]; const last = buttons.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
};
document.addEventListener('keydown', onKeyDown);
return () => { document.removeEventListener('keydown', onKeyDown); if (previousFocus?.isConnected) previousFocus.focus(); };
}, [onCancel]);
return <Dialog title="Confirm client information changes" onClose={onCancel}>
<div ref={content} className="client-information-change datahub-dialog-form">
<p>You are changing stored client information. Please check the previous and new values before confirming.</p>
<table aria-label="Client information changes"><thead><tr><th>Field</th><th>Previous value</th><th>New value</th></tr></thead>
<tbody>{changes.map(change => <tr key={change.label}><th scope="row">{change.label}</th><td>{change.previous || '—'}</td><td>{change.next || '—'}</td></tr>)}</tbody>
</table>
<footer><button ref={cancel} className="btn" type="button" onClick={onCancel}>Cancel</button>
<button className="btn primary" type="button" onClick={onConfirm}>Confirm changes</button></footer>
</div>
</Dialog>;
}

View File

@ -5,6 +5,7 @@ import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '..
import { AssessorApiClient } from '../../api/assessor-api-client';
import { defaultSecurityScoringPolicy } from '../../domain/security-scoring';
import { Dialog } from '../../components/Dialog/Dialog';
import { ClientInformationChangeDialog, type ClientInformationChange } from './ClientInformationChangeDialog';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import { FileImport } from '../file-import/FileImport';
import { stableAssessmentId, stableProjectId } from '../../shared/identifiers';
@ -113,6 +114,9 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({});
const [form, setForm] = useState<ProjectFormState | null>(null);
const [masterDataEditing, setMasterDataEditing] = useState(false);
const [pendingClientChange, setPendingClientChange] = useState<{ form: ProjectFormState; changes: ClientInformationChange[] } | null>(null);
const saveInFlight = useRef(false);
const cancelClientChange = useCallback(() => setPendingClientChange(null), []);
const [deleteTarget, setDeleteTarget] = useState<AssessmentProject | null>(null);
const [scanDeleteTarget, setScanDeleteTarget] = useState<ScanImportGroup | null>(null);
const [gpoDeleteTarget, setGpoDeleteTarget] = useState<GpoBackupStatusRecord | null>(null);
@ -575,7 +579,17 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const saveProject = async (event: FormEvent) => {
event.preventDefault();
if (!form?.name.trim() || (form.mode === 'edit' && !masterDataEditing)) return;
if (busy || pendingClientChange || !form?.name.trim() || (form.mode === 'edit' && !masterDataEditing)) return;
const changes: ClientInformationChange[] = form.project ? [
{ label: 'Name' as const, previous: form.project.name, next: form.name.trim() },
{ label: 'Consultant' as const, previous: projectConsultant(form.project), next: form.consultant.trim() }
].filter(change => change.previous !== change.next) : [];
if (changes.length) { setPendingClientChange({ form: { ...form }, changes }); return; }
await persistProject(form);
};
const persistProject = async (form: ProjectFormState) => {
if (saveInFlight.current) return;
saveInFlight.current = true;
setBusy(true);
setError(null);
setSaveFeedback(null);
@ -610,6 +624,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
setSaveFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'Saving to the database failed.' });
} finally {
setBusy(false);
saveInFlight.current = false;
}
};
const deleteProject = async () => {
@ -1003,5 +1018,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
{timeslotDialogOpen && <Dialog title="Create Timeslot" onClose={() => !busy && setTimeslotDialogOpen(false)}><form className="timeslot-dialog-form" onSubmit={createTimeslot}><label>Date<input className="input" type="date" value={timeslotDate} required onChange={event => setTimeslotDate(event.target.value)}/></label><p>Imports added afterwards are assigned to this dated assessment timeslot.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setTimeslotDialogOpen(false)}>Cancel</button><button className="btn primary" type="submit" disabled={busy || !timeslotDate}>{busy ? 'Creating …' : 'Create'}</button></footer></form></Dialog>}
{deleteTarget && <Dialog title="Permanently delete client?" onClose={() => !busy && setDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{deleteTarget.name}</strong><p>All assessments, scan reports, findings, reviews, and mappings for this client will be permanently deleted from the database. This action cannot be undone.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteProject()}>{busy ? 'Deleting …' : 'Yes, delete permanently'}</button></footer></div></Dialog>}
<OperationProgressDialog label={operationProgress}/>
{pendingClientChange && <ClientInformationChangeDialog changes={pendingClientChange.changes} onCancel={cancelClientChange}
onConfirm={() => { const approved = pendingClientChange.form; setPendingClientChange(null); void persistProject(approved); }}/>}
</div>;
}

View File

@ -112,11 +112,11 @@ export function DatahubHistoryOverview({
<span className="datahub-tenant-summary"><strong>{groups.length.toLocaleString('de-DE')}</strong><small>{groups.length === 1 ? 'Timeslot' : 'Timeslots'}</small></span>
<span className="datahub-tenant-latest">{latest ? `Latest ${formatDate(latest.fileDate)}` : 'No scan data yet'}</span>
</button>
{(selected || processingSummary.state === 'update' || processingSummary.state === 'review' || processingSummary.state === 'error') && <button
{(selected || processingSummary.state === 'update' || processingSummary.state === 'reference-update' || processingSummary.state === 'review' || processingSummary.state === 'error') && <button
className="btn datahub-tenant-show"
data-status={processingSummary.state}
type="button"
title={['update', 'review', 'error'].includes(processingSummary.state) ? processingSummary.label : 'Manage client'}
title={['update', 'reference-update', 'review', 'error'].includes(processingSummary.state) ? processingSummary.label : 'Manage client'}
onClick={() => onShow(project)}
>Manage</button>}
<button className="btn primary datahub-tenant-open" type="button" disabled={!latestOpenable} title={latestOpenable ? 'Open latest timeslot' : 'No scan data available'} onClick={() => latestOpenable && onOpen(project, latestOpenable.scanRunIds, latestOpenable.assessmentIds)}>Open</button>

View File

@ -50,6 +50,10 @@ export function ReferenceRecognitionPanel({ projectId, onChanged, onStatusChange
onStatusChanged?.(projectId, { state: 'ready', status: latestStatus });
refreshed = true;
setUpdated(latestStatus.pendingScans === 0);
if (latestStatus.pendingScans > 0) {
setFailure('update');
setError(`Update incomplete: ${latestStatus.pendingScans} ${latestStatus.pendingScans === 1 ? 'scan still needs' : 'scans still need'} a reference update.`);
}
await onChanged?.();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Reference recognition failed.');

View File

@ -166,6 +166,35 @@ function withCanonicalResultTitle(
return projected;
}
/**
* Raw Data field `Benchmark section`: the section title path of a CIS benchmark rule, read
* only from the section-score table of the rule's own scan report (`Local Policies >
* User Rights Assignment`). The path stops at the first section without an unambiguous
* title; nothing is guessed from the number. Display-only; stored data is unchanged.
*/
export const BENCHMARK_SECTION_FIELD = 'Benchmark section';
function withBenchmarkSection(
fields: OriginalField[],
occurrence: FindingOccurrence,
titles: ReadonlyMap<string, string> | undefined
): OriginalField[] {
// The rule number as Raw Data shows it in `Source Rule ID`.
const ruleIdField = fields.find(field => field.label === 'Source Rule ID');
const ruleId = ruleIdField ? fieldText(ruleIdField).trim() : '';
const parts = /^\d+(?:\.\d+)*$/.test(ruleId) ? ruleId.split('.') : [];
if (!titles?.size || parts.length < 2) return fields;
const path: string[] = [];
for (let depth = 1; depth < parts.length; depth += 1) {
const title = titles.get(parts.slice(0, depth).join('.'));
if (!title) break;
path.push(title);
}
return path.length ? [...fields, {
key: 'derived-benchmark-section', label: BENCHMARK_SECTION_FIELD, value: path.join(' > '), path: 'derived/benchmark-section'
}] : fields;
}
function displayFieldsByOccurrence(
occurrences: FindingOccurrence[],
canonicalTitleByOccurrence: ReadonlyMap<FindingOccurrence, string>
@ -359,10 +388,16 @@ export function canonicalModelsToDataset(
}
}
}
const displayFields = options.summaryOnly
// Each record reads section titles only from its own run's report; the map is shared, not copied.
const benchmarkSectionTitles = cisBenchmarkSectionTitles(models.flatMap(model => model.reportSections ?? []));
const baseDisplayFields = options.summaryOnly
? new Map(occurrences.map(occurrence => [occurrence.id,
occurrence.originalFields.filter(field => field.label !== linkedSectionFieldLabel)]))
: displayFieldsByOccurrence(occurrences, canonicalTitleByOccurrence);
const displayFields = benchmarkSectionTitles.size
? new Map(occurrences.map(occurrence => [occurrence.id, withBenchmarkSection(
fieldsFor(occurrence, baseDisplayFields), occurrence, benchmarkSectionTitles.get(occurrence.scanRunId))]))
: baseDisplayFields;
const tableSheets: DatasetSheet[] = [];
const supportingSheets: DatasetSheet[] = [];
@ -449,8 +484,6 @@ export function canonicalModelsToDataset(
))
: [];
const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean));
// Each record reads section titles only from its own run's report; the map is shared, not copied.
const benchmarkSectionTitles = cisBenchmarkSectionTitles(models.flatMap(model => model.reportSections ?? []));
if (benchmarkSectionTitles.size) for (const sheet of [aggregate, ...tableSheets]) for (const record of sheet.analysis.records) {
const titles = record.scanRunId ? benchmarkSectionTitles.get(record.scanRunId) : undefined;
if (titles?.size) record.benchmarkSectionTitles = titles;

View File

@ -2,7 +2,7 @@ import type { ImportFileRecord } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord, ReferenceRecognitionStatus } from '../../api/assessor-api-contract';
import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility';
export type DatahubClientProcessingState = 'ready' | 'checking' | 'processing' | 'update' | 'review' | 'error' | 'no-data';
export type DatahubClientProcessingState = 'ready' | 'checking' | 'processing' | 'update' | 'reference-update' | 'review' | 'error' | 'no-data';
export type DatahubReferenceReadiness = { state: 'ready'; status: ReferenceRecognitionStatus }
| { state: 'checking' | 'processing' | 'unavailable' | 'error' };
@ -23,7 +23,7 @@ export function withReferenceReadiness(
case 'ready':
if (!references.status.pendingScans) return processing.state === 'no-data' && references.status.totalScans > 0
? { state: 'ready', label: 'Processing ready across all client timeslots.' } : processing;
reference = { state: 'update', label: `${references.status.pendingScans} ${references.status.pendingScans === 1 ? 'scan requires' : 'scans require'} reference recognition. Open Manage → Catalog references; no file upload needed.` };
reference = { state: 'reference-update', label: `${references.status.pendingScans} ${references.status.pendingScans === 1 ? 'scan requires' : 'scans require'} reference recognition. Open Manage → Catalog references; no file upload needed.` };
break;
case 'error':
reference = { state: 'error', label: 'Reference recognition failed. Open Manage → Catalog references to retry.' };
@ -34,7 +34,7 @@ export function withReferenceReadiness(
default:
reference = { state: 'checking', label: references.state === 'processing' ? 'Reference recognition in progress.' : 'Checking reference recognition status.' };
}
const priority: Record<DatahubClientProcessingState, number> = { 'no-data': 0, ready: 0, checking: 1, processing: 2, review: 3, update: 4, error: 5 };
const priority: Record<DatahubClientProcessingState, number> = { 'no-data': 0, ready: 0, checking: 1, processing: 2, review: 3, update: 4, 'reference-update': 5, error: 6 };
if (priority[processing.state] === 0) return reference;
return { state: priority[processing.state] >= priority[reference.state] ? processing.state : reference.state,
label: `${processing.label} ${reference.label}` };

View File

@ -25,6 +25,34 @@ const preferenceCseLabels: Record<string, string> = {
services: 'services', 'drive-maps': 'drive maps'
};
/**
* Security-template `Registry Values` name their registry value as the identity key
* (`MACHINE\System\...\Lsa\NoLMHash`). Raw Data shows it as `Registry path`, from the
* compared identity or, without a comparison, from every contributing observation alike.
*/
function securityTemplateRegistryKey(
target: { kind: string; section?: string; key: string } | undefined, sources: UnifiedResultProjection[]
): string {
if (target) return target.kind === 'security-template' && target.section === 'Registry Values' ? target.key : '';
const keys = new Set(sources.flatMap(source => source.sourceKind === 'policy-observation'
&& source.policyDeliveryMechanism === 'security-template' && source.title.startsWith('Registry Values · ')
? [source.title.slice('Registry Values · '.length).trim()] : []));
return keys.size === 1 ? [...keys][0] : '';
}
/**
* Section of a security-template or audit setting without a baseline comparison. The
* observation title is ASSESSOR's own `<identity.section> · <identity.key>` projection
* (`policyObservationTitle`), not scanner prose; the section is taken only when every
* contributing observation states the same one.
*/
function observedPolicySection(sources: UnifiedResultProjection[]): string {
const sections = new Set(sources.flatMap(source => source.sourceKind === 'policy-observation'
&& (source.policyDeliveryMechanism === 'security-template' || source.policyDeliveryMechanism === 'advanced-audit-policy')
&& source.title.includes(' · ') ? [source.title.slice(0, source.title.indexOf(' · ')).trim()] : []));
return sections.size === 1 ? [...sections][0] : '';
}
/**
* A Group Policy Preference has no comparable technical policy setting. The retained
* action and item-level targeting decide whether and how it applies, so both stay visible
@ -93,7 +121,8 @@ export function appendPolicyObservationsToDataset(
.find(value => value && typeof value === 'object' && !Array.isArray(value) && value.kind === 'registry-policy');
const registryPath = target && 'hive' in target && target.hive ? `${target.hive}\\${target.key}`
: rawRegistry && typeof rawRegistry === 'object' && !Array.isArray(rawRegistry)
? `${entry.scope === 'user' ? 'HKEY_CURRENT_USER' : 'HKEY_LOCAL_MACHINE'}\\${rawRegistry.key}` : '';
? `${entry.scope === 'user' ? 'HKEY_CURRENT_USER' : 'HKEY_LOCAL_MACHINE'}\\${rawRegistry.key}`
: securityTemplateRegistryKey(target, entry.sources);
return {
entry,
historical: Boolean(previousVersion),
@ -136,7 +165,7 @@ export function appendPolicyObservationsToDataset(
...entry.sources.flatMap(source => source.policyDiagnosticCodes ?? [])])].join(', '),
target?.kind ?? entry.sources[0]?.policyDeliveryMechanism ?? '',
target && 'section' in target ? target.section
: preferenceCseLabels[entry.sources[0]?.policyPreferenceCse ?? ''] ?? entry.sources[0]?.policyPreferenceCse ?? '',
: preferenceCseLabels[entry.sources[0]?.policyPreferenceCse ?? ''] ?? entry.sources[0]?.policyPreferenceCse ?? observedPolicySection(entry.sources),
comparison ? guidance.get(comparison.id)?.description || (guidance.get(comparison.id)?.descriptionLoadFailed
? 'Baseline description could not be loaded. The saved comparison is retained.'
: 'Description unavailable in the referenced baseline metadata.') : '',

View File

@ -6,6 +6,7 @@ import { resultSourceToolLabel, type UnifiedResultFacetResult, type UnifiedResul
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../domain/result-db-disclosure';
import { ResultsFilterPanel } from './ResultsFilterPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState';
import { TriangleIcon } from '../../components/TriangleIcon';
import { browserDownloadAdapter, downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
import { downloadText } from '../../exports/text-download';
@ -157,7 +158,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
onClick={() => void exportFilteredResults('csv')}>{exporting === 'csv' ? 'Exporting …' : 'Export CSV'}</button>
</div>
</div>
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
{!queryResult?.items.length ? <FilterEmptyState title="No hits" onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })} onSearchAll={() => updateFilters({})}>No result matches the current filter combination.</FilterEmptyState> : <div className="results-browser-table" role="table" aria-label="Results">
<div className="results-browser-row results-browser-head" role="row">
<span>Title</span><span>Source Rule ID</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span>
</div>

View File

@ -93,7 +93,7 @@ function renderFacet(
>
{multipleSeverities && <option value={MULTI_SEVERITY_VALUE}>{compactSeveritySelection(selectedValues)}</option>}
<option value="">{facet.label}: All{facet.derivedKind === 'context-cluster' ? ` (${facet.allCount ?? 0})` : ''}</option>
{facet.values.map(value => <option key={value.value} value={value.value}>{value.label} · {value.count}</option>)}
{facet.values.map(value => <option key={value.value} value={value.value} disabled={facet.derivedKind === 'context-cluster' && value.count === 0}>{value.label} · {value.count}</option>)}
</select>;
}
const active = Boolean(selected?.minimum || selected?.maximum);
@ -152,4 +152,3 @@ export function DynamicFilterControls({
{facets.slice(primaryCount).map(render)}
</>;
}

View File

@ -0,0 +1,36 @@
import { managementThemes, managementFindingThemeAssignments, managementWorkspaceDeviationKeys } from '../../reporting/management-business-impact';
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
import type { DynamicFacet } from './dynamic-facets';
import { readEnginePreferences } from '../../app/engine-preferences';
/** Published Report themes select their existing primary Context categories; no second assignment. */
export const businessImpactControls = managementThemes.map(theme => ({
id: theme.id,
label: ({ control: 'Control & trust', information: 'Information & data', continuity: 'Operational continuity', resilience: 'Resilience & recovery' } as Record<string, string>)[theme.id],
clusterIds: [...theme.clusterIds]
}));
export const BUSINESS_IMPACT_FACET_ID = 'report:business-impact';
const indexes = new WeakMap<ContextClusterIndex, DynamicFacet>();
/** Independent selection over published primary categories; the Context Cluster selection stays untouched. */
export function withBusinessImpactFacet(facets: DynamicFacet[], context?: ContextClusterIndex): DynamicFacet[] {
const engines = readEnginePreferences();
if (!context || !engines.report || !engines.context) return facets;
let facet = indexes.get(context);
if (!facet) {
const assignments = managementFindingThemeAssignments(context.publication);
facet = { id: BUSINESS_IMPACT_FACET_ID, label: 'Business Impact', source: 'derived', kind: 'category',
values: businessImpactControls.map(theme => ({ value: theme.id, label: theme.label, count: 0 })),
valuesFor: record => {
const key = managementWorkspaceDeviationKeys([record], () => true)[0];
const theme = assignments.get(key);
return theme ? [theme] : [];
} };
indexes.set(context, facet);
}
const contextPosition = facets.findIndex(item => item.id === 'context:cluster');
const source = contextPosition >= 0 ? contextPosition : facets.findIndex(item => item.id === 'provenance:source-scan');
return source < 0 ? [...facets, facet] : [...facets.slice(0, source), facet, ...facets.slice(source)];
}

View File

@ -12,7 +12,7 @@ import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hie
import { cisBenchmarkSectionTitles, cisCategoryTitle, compareCisCategories } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache';
import { toolIdForRecord } from '../../importers/status-normalization';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord, unclassifiedSecurityDomain } from '../dashboard/security-domains';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord } from '../../evaluation/security-domains';
export type DynamicFacetKind = 'category' | 'number-range' | 'date-range';
export type DynamicFacetSource = 'field' | 'profile-status' | 'review' | 'provenance' | 'derived';
@ -244,19 +244,17 @@ function failingGpoBackupFacet(records: FindingRecord[]): DynamicFacet | null {
};
}
function securityDomainFacet(records: FindingRecord[]): DynamicFacet | null {
function securityDomainFacet(records: FindingRecord[], toolId?: string): DynamicFacet | null {
if (!records.length) return null;
const counts = new Map<string, number>();
records.forEach(record => {
const id = securityDomainIdForRecord(record);
const id = securityDomainIdForRecord(record, toolId);
counts.set(id, (counts.get(id) ?? 0) + 1);
});
const values: DynamicFacetValue[] = securityDomainCatalog.map(domain =>
({ value: domain.id, label: domain.label, count: counts.get(domain.id) ?? 0 }));
if (counts.has(unclassifiedSecurityDomain.id)) values.push({ value: unclassifiedSecurityDomain.id,
label: unclassifiedSecurityDomain.label, count: counts.get(unclassifiedSecurityDomain.id)! });
return { id: SECURITY_DOMAIN_FACET_ID, label: 'Security Domain', source: 'derived', kind: 'category',
derivedKind: 'security-domain', preserveEmptyValues: true, values };
derivedKind: 'security-domain', preserveEmptyValues: true, fallbackToolId: toolId, values };
}
function policyBaselineFacet(records: FindingRecord[], columnIndex: number): DynamicFacet | null {
@ -645,7 +643,7 @@ function buildDynamicFacets(
// Arbeitsreihenfolge: Ergebnisstatus und die fachlichen Achsen zuerst,
// danach Review- und Herkunftsfacetten.
const baselines = policyBaselineFacet(sheet.analysis.records, sheet.analysis.headers.indexOf('Baseline'));
const available = [profileFacet, securityDomainFacet(sheet.analysis.records), failingGpoBackupFacet(sheet.analysis.records),
const available = [profileFacet, securityDomainFacet(sheet.analysis.records, sheet.toolId), failingGpoBackupFacet(sheet.analysis.records),
baselines, cisCategories, ...fields.filter(field => !baselines || field.label !== 'Baseline'), ...reviewFacets, sourceScan]
.filter((facet): facet is DynamicFacet => Boolean(facet));
const resolvedContextToolId = contextToolId ?? sheet.toolId;
@ -773,7 +771,7 @@ function valueForFacet(record: FindingRecord, facet: DynamicFacet, reviews: Reco
function valuesForFacet(record: FindingRecord, facet: DynamicFacet, reviews: Record<string, Review>): string[] {
if (facet.valuesFor) return facet.valuesFor(record);
if (facet.derivedKind === 'policy-baseline') return record.policyBaselineLabels ?? [];
if (facet.derivedKind === 'security-domain') return [securityDomainIdForRecord(record)];
if (facet.derivedKind === 'security-domain') return [securityDomainIdForRecord(record, facet.fallbackToolId)];
if (facet.derivedKind === 'policy-failing-backup') {
const sources = record.policyFailingBackupSources;
return sources?.length ? [ALL_FAILING_GPO_BACKUPS, ...sources.map(source => source.id)] : [];

View File

@ -12,6 +12,8 @@ import {
} from './dynamic-facets';
import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache';
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls';
import { readEnginePreferences } from '../../app/engine-preferences';
export interface RecordFilterOptions {
activeProfile: ImportProfile;
@ -35,7 +37,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
return [options.activeProfile, options.dataset, options.reviews, options.search,
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
JSON.stringify(options.facetFilters ?? {}), options.contextClusters];
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, readEnginePreferences()];
}
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
@ -48,9 +50,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withContextClusterFacet(contextToolId === 'group-policy'
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters);
: baseFacets, options.contextClusters), options.contextClusters);
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
const filtered = sheet.analysis.records.filter(record => {
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;

View File

@ -0,0 +1,11 @@
import type { useApp } from '../../app/AppProvider';
/** Show all results in the current client/timeslot/sheet, separately from the normal profile reset. */
export function searchAllFindings(app: Pick<ReturnType<typeof useApp>,
'resetFilters' | 'setStatusFilter' | 'setActionOnly' | 'setSearch' | 'setPathFindingIds'>) {
app.resetFilters();
app.setStatusFilter('all');
app.setActionOnly(false);
app.setSearch('');
app.setPathFindingIds([]);
}

View File

@ -8,6 +8,7 @@ import {
} from './dynamic-facets';
import { filterAndSortRecords, recordFilterKeys, type RecordFilterOptions } from './filter-records';
import { withContextClusterFacet } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls';
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
const cache = createSheetDerivationCache<DynamicFacet[]>();
@ -21,11 +22,14 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
return cache(sheet, recordFilterKeys(query), () => {
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withContextClusterFacet(contextToolId === 'group-policy'
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters);
: baseFacets, options.contextClusters), options.contextClusters);
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
return recountFacetsAgainstActiveFilters({ facets, records, filters: options.facetFilters ?? emptyFilters,
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'
? { ...facet, preserveEmptyValues: false } : facet);
return recountFacetsAgainstActiveFilters({ facets: selectableFacets, records, filters: options.facetFilters ?? emptyFilters,
reviews: options.reviews, statusFilter: options.statusFilter, matchesStatus: options.activeProfile.matchesStatus });
});
}

View File

@ -4,11 +4,13 @@ import type { DatasetSheet, ImportedDataset } from '../../domain/dataset';
import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { measureNavigationStep } from '../../app/navigation-diagnostics';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, unclassifiedSecurityDomain } from '../dashboard/security-domains';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog } from '../dashboard/security-domains';
import { type DynamicFacetFilters } from '../search-filter/dynamic-facets';
import { selectWorkFacets } from '../search-filter/work-facets';
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useEnginePreferences } from '../../app/engine-preferences';
interface SecurityDomainOptions {
sheet: DatasetSheet | undefined;
@ -51,10 +53,6 @@ export function summarizeSecurityDomains(options: SecurityDomainOptions): Sideba
label: domain.label,
count: counts.get(domain.id) ?? 0
}));
if (counts.has(unclassifiedSecurityDomain.id)) domains.push({
...unclassifiedSecurityDomain,
count: counts.get(unclassifiedSecurityDomain.id) ?? 0
});
return { facetId: SECURITY_DOMAIN_FACET_ID, domains };
}
@ -72,9 +70,19 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
pathFindingIds,
search,
actionOnly,
setFacetFilter
setFacetFilter,
setStatusFilter
} = useApp();
const contextClusters = useContextClusterIndex();
const engines = useEnginePreferences();
const impactCounts = useMemo(() => {
if (!engines.report || !engines.context || !contextClusters) return undefined;
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
pathFindingIds, search, actionOnly, contextClusters }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
if (!facet) return undefined;
const counts = new Map(facet.values.map(value => [value.value, value.count]));
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
}, [engines.report, engines.context, contextClusters, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
sheet,
dataset,
@ -110,6 +118,18 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
<strong>{domain.count.toLocaleString('en-US')}</strong>
</button>;
})}
{impactCounts && <section className="sidebar-business-impact" aria-labelledby="sidebar-business-impact-heading">
<h2 id="sidebar-business-impact-heading">Possible Business Impact</h2>
{impactCounts.map(theme => {
const active = facetFilters[BUSINESS_IMPACT_FACET_ID]?.values?.includes(theme.id) ?? false;
return <button type="button" className="sidebar-domain-button" key={theme.id} aria-pressed={active}
aria-label={`${theme.label}: ${theme.count.toLocaleString('en-US')}`} disabled={!active && theme.count === 0}
onClick={() => {
setStatusFilter('deviation');
setFacetFilter(BUSINESS_IMPACT_FACET_ID, active ? null : { values: [theme.id] });
}}><span>{theme.label}</span><strong>{theme.count.toLocaleString('en-US')}</strong></button>;
})}
</section>}
</section>
);
}

View File

@ -0,0 +1,72 @@
import type { JsonValue } from '../domain/canonical-model';
import type { ClientInformationArea } from '../domain/client-information';
import type { SourceLocator } from '../domain/import-intermediate';
import type { EnvironmentObservationCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction';
import { extractMarkdownTables } from '../shared/markdown';
import { isJsonObject } from './embedded-report-json';
/** Exact native IDs, not titles, tag guesses or pass/fail-derived settings.
* Source and scope: docs/CLIENT_INFORMATION.md. These are reported test details,
* never a complete tenant inventory or a reconstructed effective policy.
*/
const GROUPS: Array<{ area: ClientInformationArea; property: string; ids: string[] }> = [
{ area: 'cloud-access', property: 'cloud.authentication', ids: [
'EIDSCA.AF01', 'EIDSCA.AF02', 'EIDSCA.AF03', 'EIDSCA.AF04', 'EIDSCA.AF05', 'EIDSCA.AF06',
'EIDSCA.AG01', 'EIDSCA.AG02', 'EIDSCA.AG03', 'EIDSCA.AM01', 'EIDSCA.AM02', 'EIDSCA.AM03',
'EIDSCA.AM04', 'EIDSCA.AM06', 'EIDSCA.AM07', 'EIDSCA.AM09', 'EIDSCA.AM10',
'EIDSCA.AP01', 'EIDSCA.AS04', 'EIDSCA.AT01', 'EIDSCA.AT02', 'EIDSCA.AV01'
] },
{ area: 'cloud-access', property: 'cloud.conditional_access', ids: [
'MT.1001', 'MT.1003', 'MT.1004', 'MT.1005', 'MT.1006', 'MT.1007', 'MT.1008', 'MT.1009',
'MT.1010', 'MT.1011', 'MT.1012', 'MT.1013', 'MT.1014', 'MT.1015', 'MT.1016', 'MT.1017',
'MT.1018', 'MT.1019', 'MT.1020', 'MT.1021', 'MT.1035', 'MT.1036', 'MT.1038'
] },
{ area: 'cloud-roles', property: 'cloud.roles', ids: [
'MT.1025', 'MT.1026', 'MT.1027', 'MT.1028', 'MT.1029', 'MT.1030', 'MT.1031', 'MT.1032',
'CISA.MS.AAD.7.1', 'CISA.MS.AAD.7.2', 'CISA.MS.AAD.7.3', 'CISA.MS.AAD.7.4', 'CISA.MS.AAD.7.5', 'CISA.MS.AAD.7.6'
] },
{ area: 'cloud-apps', property: 'cloud.consent', ids: [
'EIDSCA.AP08', 'EIDSCA.AP09', 'EIDSCA.AP10', 'EIDSCA.CP01', 'EIDSCA.CP03', 'EIDSCA.CP04',
'EIDSCA.CR01', 'EIDSCA.CR02', 'EIDSCA.CR03', 'EIDSCA.CR04',
'CISA.MS.AAD.5.1', 'CISA.MS.AAD.5.2', 'CISA.MS.AAD.5.3', 'CISA.MS.AAD.5.4'
] },
{ area: 'cloud-sharing', property: 'cloud.sharing', ids: [
'EIDSCA.AP04', 'EIDSCA.AP07', 'EIDSCA.ST08', 'EIDSCA.ST09',
'CISA.MS.SHAREPOINT.1.1', 'CISA.MS.SHAREPOINT.1.2', 'CISA.MS.SHAREPOINT.1.3',
'CISA.MS.SHAREPOINT.2.1', 'CISA.MS.SHAREPOINT.2.2', 'CISA.MS.SHAREPOINT.3.1',
'CISA.MS.SHAREPOINT.3.2', 'CISA.MS.SHAREPOINT.3.3'
] }
];
const BY_ID = new Map(GROUPS.flatMap(group => group.ids.map(id => [id, group] as const)));
export const isMaesterClientInformationRule = (id: string) => BY_ID.has(id);
export function maesterClientInformation(fileName: string, root: Record<string, JsonValue>, rootPath: string, ordinal: number,
sourceForTest?: (index: number) => SourceLocator) {
const observations: EnvironmentObservationCandidate[] = [];
const sections: ScannerReportSectionCandidate[] = [];
const tests = Array.isArray(root.Tests) ? root.Tests : [];
for (const [index, test] of tests.entries()) {
if (!isJsonObject(test) || typeof test.Id !== 'string') continue;
const group = BY_ID.get(test.Id);
const value = isJsonObject(test.ResultDetail) ? test.ResultDetail.TestResult : undefined;
if (!group || typeof value !== 'string' || !value.trim() || /^(pass(?:ed)?|fail(?:ed)?|skipped|notrun|error|investigate)$/i.test(value.trim())) continue;
const sectionKey = `maester-client-information-${ordinal}-${index}`;
const path = `${rootPath}.Tests[${index}].ResultDetail.TestResult`;
const source = sourceForTest?.(index) ?? { fileName, objectPath: path };
const observedAt = typeof root.ExecutedAt === 'string' && !Number.isNaN(Date.parse(root.ExecutedAt)) ? new Date(root.ExecutedAt).toISOString() : undefined;
const state = ['Passed', 'Failed', 'Investigate'].includes(String(test.Result)) ? 'observed' as const : 'not-assessed' as const;
const attributes = { clientInformationArea: group.area, informationKind: 'reported-test-evidence',
inventoryComplete: false, sourceRuleId: test.Id, sourceOutcome: test.Result ?? null, sourceField: sourceForTest ? 'table-result-cell' : 'ResultDetail.TestResult',
clientInformationMappingVersion: '1.0.0' };
const label = typeof test.Title === 'string' && test.Title.trim() ? test.Title : test.Id;
const tables = extractMarkdownTables(value).map(table => ({ ...table, name: '', presentation: 'inline' as const }));
sections.push({ version: 1, sectionKey, label, ordinal: ordinal + sections.length, state,
tables, source, attributes });
observations.push({ version: 1, sectionKey,
subject: { type: 'entra-tenant', identity: typeof root.TenantId === 'string' ? root.TenantId : undefined,
label: typeof root.TenantName === 'string' ? root.TenantName : undefined },
property: `${group.property}.${test.Id.toLowerCase()}.reported_evidence`, label,
originalValue: value, valueType: 'string', state, observedAt, source, attributes });
}
return { observations, sections };
}

View File

@ -2,6 +2,7 @@ import type { JsonValue } from '../domain/canonical-model';
import type { ImportDiagnostic } from '../domain/import-intermediate';
import type { EnvironmentObservationCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction';
import { isJsonObject } from './embedded-report-json';
import { maesterClientInformation } from './maester-client-information';
const COUNTERS: Record<string, string> = { FailedCount: 'Failed', PassedCount: 'Passed', ErrorCount: 'Error', InvestigateCount: 'Investigate', SkippedCount: 'Skipped', NotRunCount: 'NotRun' };
@ -89,5 +90,8 @@ export function maesterEnvironmentContext(fileName: string, root: Record<string,
source: { fileName, objectPath: `${path}.Tests` }, attributes: { derived: true, derivation: 'count-test-entries', status }
});
}
const clientInformation = maesterClientInformation(fileName, root, path, ordinal);
reportSections.push(...clientInformation.sections);
environmentObservations.push(...clientInformation.observations);
return { reportSections, environmentObservations };
}

View File

@ -151,7 +151,7 @@ function testCandidate(fileName: string, test: Record<string, JsonValue>, path:
export const maesterHtmlSourceExtractor: SourceExtractor = {
id: 'maester-html-source-extractor',
version: '1.1.0',
version: '1.2.0',
detect(document) {
const payload = maesterPayload(document);
const tests = payload.roots.reduce((count, root) => count + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0);

View File

@ -0,0 +1,45 @@
import type { DecodedSourceDocument, ImportTable } from '../domain/import-intermediate';
import type { SourceExtractor } from '../domain/source-extraction';
import { genericTableSourceExtractor } from './generic-table-source-extractor';
import { isMaesterClientInformationRule, maesterClientInformation } from './maester-client-information';
function columns(table: ImportTable) {
const header = table.headerCandidates[0];
const headers = header?.headers.map(value => value.trim().toLowerCase()) ?? [];
const id = headers.findIndex(value => ['id', 'test id', 'testid'].includes(value));
const status = headers.indexOf('status');
const result = headers.indexOf('resultdetail') >= 0 ? headers.indexOf('resultdetail') : status >= 0 ? headers.indexOf('result') : -1;
const outcome = status >= 0 ? status : headers.indexOf('result');
return { header, headers, id, result, outcome, title: headers.indexOf('title') };
}
function matches(document: DecodedSourceDocument) {
if (!['CSV', 'Excel'].includes(document.format)) return [];
return document.tables.filter(table => {
const cols = columns(table);
return cols.id >= 0 && cols.result >= 0 && cols.outcome >= 0 && cols.result !== cols.outcome
&& table.rows.some(row => row.index !== cols.header?.rowIndex && isMaesterClientInformationRule(row.cells[cols.id]?.decodedValue.trim() ?? ''));
});
}
/** Extends the generic table path; never consumes or reorders workbook finding/detail tables. */
export const maesterTableContextSourceExtractor: SourceExtractor = {
id: 'maester-table-context-source-extractor', version: '1.0.0',
detect(document) {
return matches(document).length ? { score: 0.95, toolId: 'maester', evidence: ['Exact Maester rule IDs with distinct status and report evidence columns.'] }
: { score: 0, evidence: [] };
},
extract(document) {
const base = genericTableSourceExtractor.extract(document);
const contexts = matches(document).map(table => {
const cols = columns(table);
const rows = table.rows.filter(row => row.index !== cols.header?.rowIndex);
return maesterClientInformation(document.file.name, { Tests: rows.map(row => ({
Id: row.cells[cols.id]?.decodedValue.trim() ?? '', Title: row.cells[cols.title]?.decodedValue ?? '',
Result: row.cells[cols.outcome]?.decodedValue.trim() ?? '', ResultDetail: { TestResult: row.cells[cols.result]?.rawValue ?? null }
})) }, '', table.index, index => ({ ...rows[index].cells[cols.result].source,
fileName: document.file.name, tableIndex: table.index, rowIndex: rows[index].index, columnIndex: cols.result }));
});
return { ...base, reportSections: contexts.flatMap(item => item.sections), environmentObservations: contexts.flatMap(item => item.observations) };
}
};

View File

@ -5,6 +5,7 @@ import { stringifyCell } from '../shared/text';
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
import { maesterHtmlSourceExtractor } from './maester-html-source-extractor';
import { maesterTableContextSourceExtractor } from './maester-table-context-source-extractor';
import { purpleKnightHtmlSourceExtractor } from './purple-knight-html-source-extractor';
import { purpleKnightSummarySourceExtractor } from './purple-knight-summary-source-extractor';
import { genericTableSourceExtractor } from './generic-table-source-extractor';
@ -68,6 +69,7 @@ export const sourceExtractors: SourceExtractor[] = [
purpleKnightHtmlSourceExtractor,
purpleKnightSummarySourceExtractor,
maesterHtmlSourceExtractor,
maesterTableContextSourceExtractor,
nativeObjectSourceExtractor,
genericTableSourceExtractor
];

View File

@ -27,6 +27,7 @@ import './styles/legacy-views.css';
import './styles/obsidian-graph.css';
import './styles/functional-icons.css';
import './styles/tile-contract.css';
import './styles/filter-empty-state.css';
if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavigation') === '1') {
void import('./app/navigation-diagnostics').then(module => module.observeNavigationPerformance());

View File

@ -192,6 +192,8 @@ export interface AssessorRepository {
listReferenceRuns(projectId: string): Promise<import('../domain/reference-recognition').ReferenceRecognitionRun[]>;
listReferenceCatalogs(projectId: string): Promise<import('../domain/reference-recognition').ReferenceCatalogSnapshot[]>;
readonly revision: number;
/** Database readers must refresh the committed revision instead of trusting a process-local cache. */
getCommittedRevision?(): Promise<number>;
listProjects(): Promise<AssessmentProject[]>;
getProject(projectId: string): Promise<AssessmentProject | null>;

View File

@ -0,0 +1,60 @@
import { CLIENT_INFORMATION_AREAS, selectClientInformation, type ClientInformationQuery, type ClientInformationResult } from '../domain/client-information';
import type { AssessorRepository } from './assessor-repository';
import { RepositoryConflictError, RepositoryValidationError } from './repository-errors';
/** Published Core read-only port. Engines bind it to a read-only repository/database session. */
export interface ClientInformationReadPort {
queryClientInformation(query: ClientInformationQuery): Promise<ClientInformationResult>;
}
const states = ['observed', 'explicitly-absent', 'empty', 'not-assessed', 'access-denied', 'license-restricted', 'unsupported', 'parse-failed', 'unknown'];
export function validateClientInformationQuery(query: ClientInformationQuery): void {
if (typeof query.projectId !== 'string' || !query.projectId.trim()) throw new RepositoryValidationError('Client Information requires a project.');
if (query.assessmentId !== undefined && (typeof query.assessmentId !== 'string' || !query.assessmentId.trim()))
throw new RepositoryValidationError('Invalid Client Information assessment.');
for (const [name, values] of Object.entries({ scanRunIds: query.scanRunIds, areas: query.areas, properties: query.properties,
subjectTypes: query.subjectTypes, subjectIds: query.subjectIds, toolIds: query.toolIds, states: query.states })) {
if (values !== undefined && (!Array.isArray(values) || values.length > 100
|| values.some(value => typeof value !== 'string' || !value.trim() || value.length > 500)))
throw new RepositoryValidationError(`Invalid Client Information selector: ${name}.`);
}
if (query.areas?.some(area => !Object.hasOwn(CLIENT_INFORMATION_AREAS, area)) || query.states?.some(state => !states.includes(state)))
throw new RepositoryValidationError('Unknown Client Information area or state.');
for (const [name, value, maximum] of [['offset', query.offset, Number.MAX_SAFE_INTEGER], ['limit', query.limit, 200],
['sectionOffset', query.sectionOffset, Number.MAX_SAFE_INTEGER], ['sectionLimit', query.sectionLimit, 50]] as const) {
if (value !== undefined && (!Number.isSafeInteger(value) || value < (name.endsWith('ffset') ? 0 : 1) || value > maximum))
throw new RepositoryValidationError(`Invalid Client Information ${name}.`);
}
if (query.search !== undefined && (typeof query.search !== 'string' || query.search.length > 500))
throw new RepositoryValidationError('Client Information search is too long.');
if (query.includeSuperseded !== undefined && typeof query.includeSuperseded !== 'boolean')
throw new RepositoryValidationError('Invalid Client Information history selector.');
}
export function clientInformationReadPortFrom(
source: Pick<AssessorRepository, 'getEnvironmentSnapshot'>,
currentRevision: () => Promise<number>
): ClientInformationReadPort {
return Object.freeze({
async queryClientInformation(input: ClientInformationQuery): Promise<ClientInformationResult> {
const query = structuredClone(input);
validateClientInformationQuery(query);
for (let attempt = 0; attempt < 3; attempt++) {
const before = await currentRevision();
const snapshot = await source.getEnvironmentSnapshot(query);
const after = await currentRevision();
if (!Number.isSafeInteger(after) || after < 0) throw new RepositoryValidationError('Invalid Client Information revision.');
if (before !== after) continue;
if (snapshot.projectId !== query.projectId || (query.assessmentId && snapshot.assessmentId !== query.assessmentId)
|| (query.scanRunIds?.length && (snapshot.scanRunIds.some(id => !query.scanRunIds!.includes(id))
|| query.scanRunIds.some(id => !snapshot.scanRunIds.includes(id)))))
throw new RepositoryValidationError('Client Information returned a different scope.');
const runs = new Set(snapshot.scanRunIds);
if ([...snapshot.observations, ...snapshot.sections].some(item => !runs.has(item.scanRunId)))
throw new RepositoryValidationError('Client Information contains an out-of-scope source.');
return selectClientInformation(snapshot, query, after);
}
throw new RepositoryConflictError('Client Information changed during loading. Please retry.');
}
});
}

View File

@ -756,7 +756,10 @@ export class LocalAssessorRepository implements AssessorRepository {
const assessmentIds = new Set(this.state.assessments
.filter(item => item.projectId === query.projectId && (!query.assessmentId || item.id === query.assessmentId))
.map(item => item.id));
const allowedRuns = this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId);
if (query.assessmentId && !assessmentIds.has(query.assessmentId)) {
throw new RepositoryValidationError('Environment Snapshot verweist auf ein Assessment außerhalb des Projekts.');
}
const allowedRuns = this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && (query.includeSuperseded || !item.supersededByScanRunId));
const requested = query.scanRunIds?.length ? new Set(query.scanRunIds) : undefined;
if (requested && [...requested].some(id => !allowedRuns.some(run => run.id === id))) {
throw new RepositoryValidationError('Environment Snapshot verweist auf einen Scan-Lauf außerhalb des Projekts oder Assessments.');

View File

@ -1,20 +1,32 @@
export type ActivityState = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
export interface Activity { id: number; area: string; label: string; state: ActivityState; startedAt: number; endedAt?: number; }
export interface Activity { id: number; area: string; label: string; state: ActivityState; startedAt: number; endedAt?: number; operationKey?: string; }
export const isActiveActivity = (row: Activity) => row.state === 'running' || row.state === 'queued';
let sequence = 0;
let snapshot: readonly Activity[] = [];
const listeners = new Set<() => void>();
const latestOperations = new Map<string, { id: number; state: ActivityState }>();
let status = { ready: false, failed: 0, cancelled: 0, generation: 0 };
export const activityStatusSnapshot = () => status;
export const activitySnapshot = () => snapshot;
export const subscribeActivities = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
function publish(rows: readonly Activity[]) {
const completed = rows.filter(row => !isActiveActivity(row)).slice(-40);
for (const row of rows) {
const key = row.operationKey ?? JSON.stringify([row.area, row.label]);
if (row.id >= (latestOperations.get(key)?.id ?? 0)) latestOperations.set(key, { id: row.id, state: row.state });
}
const completed = rows.filter(row => row.state === 'completed').slice(-40);
const keep = new Set(completed.map(row => row.id));
snapshot = rows.filter(row => isActiveActivity(row) || keep.has(row.id));
snapshot = rows.filter(row => row.state !== 'completed' || keep.has(row.id));
const latest = [...latestOperations.values()];
const failed = latest.filter(row => row.state === 'failed').length;
const cancelled = latest.filter(row => row.state === 'cancelled').length;
status = { ready: latest.length > 0 && !snapshot.some(isActiveActivity) && latest.every(row => row.state === 'completed'),
failed, cancelled, generation: latest.length ? sequence : 0 };
listeners.forEach(listener => listener());
}
export function beginActivity(area: string, label: string) {
export function beginActivity(area: string, label: string, operationKey = JSON.stringify([area, label])) {
const id = ++sequence;
publish([...snapshot, { id, area, label, state: 'running', startedAt: Date.now() }]);
publish([...snapshot, { id, area, label, operationKey, state: 'running', startedAt: Date.now() }]);
const finish = (state: ActivityState) => publish(snapshot.map(row => row.id === id && isActiveActivity(row)
? { ...row, state, endedAt: Date.now() } : row));
return { queue: () => publish(snapshot.map(row => row.id === id && isActiveActivity(row) ? { ...row, state: 'queued' } : row)),
@ -22,12 +34,16 @@ export function beginActivity(area: string, label: string) {
update: (label: string) => publish(snapshot.map(row => row.id === id && isActiveActivity(row) ? { ...row, label } : row)),
complete: () => finish('completed'), fail: () => finish('failed'), cancel: () => finish('cancelled') };
}
export async function trackActivity<T>(area: string, label: string, run: () => Promise<T>): Promise<T> {
const activity = beginActivity(area, label);
export async function trackActivity<T>(area: string, label: string, run: () => Promise<T>, operationKey?: string): Promise<T> {
const activity = beginActivity(area, label, operationKey);
try { const result = await run(); activity.complete(); return result; }
catch (error) { if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') activity.cancel(); else activity.fail(); throw error; }
}
export function clearFinishedActivities() { publish(snapshot.filter(row => isActiveActivity(row))); }
export function clearFinishedActivities() { latestOperations.clear(); publish(snapshot.filter(row => isActiveActivity(row))); }
/** Success history can be cleared without losing unresolved failures or cancellations. */
export function clearCompletedActivities() { publish(snapshot.filter(row => row.state !== 'completed')); }
/** Acknowledge one terminal activity; active operations can never be dismissed. */
export function dismissActivity(id: number) { publish(snapshot.filter(row => row.id !== id || isActiveActivity(row))); }
/** Let the browser display a local operation before CPU-bound preparation starts. */
export const showActivityFrame = () => listeners.size
? new Promise<void>(resolve => setTimeout(resolve, 16))

View File

@ -0,0 +1,51 @@
import type { SecurityDomainMapping } from '../security-domain-mapping';
/**
* CIS-CAT: the benchmark section title path of the same scan report (Raw Data
* `Benchmark section`), never the section number alone, because benchmarks number
* their sections differently. Each key segment must start the report's title at the
* same depth; the most specific key wins.
*/
export const cisCatSecurityDomains: SecurityDomainMapping = {
version: '1.0.0',
profileId: 'cis-cat',
toolNames: ['CIS-CAT', 'cis-cat'],
ruleSets: [{
tier: 2, method: 'report-section', field: 'Benchmark section', match: 'section-path',
entries: [
['Account Policies', 'identity'],
['Local Policies', 'hardening'],
['Local Policies > User Rights Assignment', 'privilege'],
['Local Policies > Audit Policy', 'recovery'],
['Local Policies > Security Options > Accounts', 'identity'],
['Local Policies > Security Options > Audit', 'recovery'],
['Local Policies > Security Options > Domain controller', 'infrastructure'],
['Local Policies > Security Options > Domain member', 'identity'],
['Local Policies > Security Options > Interactive logon', 'identity'],
['Local Policies > Security Options > Network security', 'identity'],
['Local Policies > Security Options > User Account Control', 'privilege'],
['Event Log', 'recovery'],
['System Services', 'hardening'],
['Windows Defender Firewall', 'hardening'],
['Windows Firewall', 'hardening'],
['Advanced Audit Policy Configuration', 'recovery'],
['Administrative Templates', 'hardening'],
['Administrative Templates (Computer) > LAPS', 'privilege'],
['Administrative Templates (Computer) > System > Audit Process Creation', 'recovery'],
['Administrative Templates (Computer) > System > Credentials Delegation', 'privilege'],
['Administrative Templates (Computer) > System > Kerberos', 'identity'],
['Administrative Templates (Computer) > System > Local Security Authority', 'identity'],
['Administrative Templates (Computer) > System > Logon', 'identity'],
['Administrative Templates (Computer) > System > Net Logon', 'identity'],
['Administrative Templates (Computer) > System > Security Account Manager', 'identity'],
['Administrative Templates (Computer) > Windows Components > Microsoft account', 'identity'],
['Administrative Templates (Computer) > Windows Components > Biometrics', 'identity'],
['Administrative Templates (Computer) > Windows Components > Credential User Interface', 'identity'],
['Administrative Templates (Computer) > Windows Components > Event Log Service', 'recovery'],
['Administrative Templates (Computer) > Windows Components > Windows Hello for Business', 'identity'],
['Administrative Templates (Computer) > Windows Components > Windows Logon Options', 'identity'],
['Administrative Templates (Computer) > Windows Components > Windows PowerShell', 'recovery']
]
}],
fallback: { domain: 'hardening', reason: 'CIS-CAT assesses the configuration hardening of a system.' }
};

View File

@ -0,0 +1,82 @@
import type { SecurityDomainMapping } from '../security-domain-mapping';
/**
* GPO settings: the technical policy identity as Raw Data shows it. Security-template and
* audit sections first; registry settings by the longest matching key (hive removed,
* case-insensitive). `Registry Values` in a security template carries a registry key and
* is therefore decided by that key.
*/
export const groupPolicySecurityDomains: SecurityDomainMapping = {
version: '1.0.0',
profileId: 'group-policy',
toolNames: ['GPOs', 'group-policy'],
ruleSets: [{
tier: 4, method: 'policy-section', field: 'Policy type', match: 'exact',
entries: [['advanced-audit-policy', 'recovery']]
}, {
tier: 4, method: 'policy-section', field: 'Policy section', match: 'exact',
when: { field: 'Policy type', equals: 'security-template' },
entries: [
['Privilege Rights', 'privilege'],
['Group Membership', 'privilege'],
['System Access', 'identity'],
['Kerberos Policy', 'identity'],
['Event Audit', 'recovery'],
['System Log', 'recovery'],
['Security Log', 'recovery'],
['Application Log', 'recovery'],
['Service General Setting', 'hardening'],
['Registry Keys', 'hardening']
]
}, {
tier: 4, method: 'registry-key', field: 'Registry path', match: 'prefix', boundary: '\\',
entries: [
['Software\\Policies\\Microsoft\\Windows\\EventLog', 'recovery'],
['System\\CurrentControlSet\\Services\\EventLog', 'recovery'],
['Software\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging', 'recovery'],
['Software\\Policies\\Microsoft\\Windows\\PowerShell\\ModuleLogging', 'recovery'],
['Software\\Policies\\Microsoft\\Windows\\PowerShell\\Transcription', 'recovery'],
['Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit', 'recovery'],
['System\\CurrentControlSet\\Control\\Lsa', 'identity'],
['System\\CurrentControlSet\\Control\\SecurityProviders\\WDigest', 'identity'],
['System\\CurrentControlSet\\Services\\Netlogon\\Parameters', 'identity'],
['Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Kerberos', 'identity'],
['Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\KDC', 'identity'],
['Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon', 'identity'],
['Software\\Policies\\Microsoft\\Biometrics', 'identity'],
['Software\\Policies\\Microsoft\\PassportForWork', 'identity'],
// Security-template `Registry Values` carry the value name: User Account Control and interactive logon.
...['EnableLUA', 'ConsentPromptBehaviorAdmin', 'ConsentPromptBehaviorUser', 'FilterAdministratorToken',
'EnableInstallerDetection', 'EnableSecureUIAPaths', 'EnableVirtualization', 'PromptOnSecureDesktop',
'ValidateAdminCodeSignatures', 'EnableUIADesktopToggle', 'TypeOfAdminApprovalMode']
.map(value => [`Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\${value}`, 'privilege'] as const),
...['DontDisplayLastUserName', 'DisableCAD', 'InactivityTimeoutSecs', 'LegalNoticeCaption', 'LegalNoticeText',
'ScForceOption', 'DontDisplayUserName', 'DontDisplayLockedUserId']
.map(value => [`Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\${value}`, 'identity'] as const),
['Software\\Policies\\Microsoft\\Windows\\CredentialsDelegation', 'privilege'],
['Software\\Policies\\Microsoft Services\\AdmPwd', 'privilege'],
['Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\LAPS', 'privilege'],
['System\\CurrentControlSet\\Services\\NTDS', 'infrastructure'],
['Software\\Policies\\Microsoft\\SystemCertificates', 'infrastructure'],
// Declared hardening areas, so the tier-5 share only counts keys the table does not know.
...[
'Software\\Microsoft\\Internet Explorer', 'Software\\Policies\\Microsoft\\Internet Explorer',
'Software\\Policies\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
'Software\\Policies\\Microsoft\\Office', 'Software\\Microsoft\\Office', 'Software\\WOW6432Node\\Microsoft\\Office',
'Software\\Policies\\Microsoft\\VBA', 'Software\\Policies\\Microsoft\\Edge', 'Software\\Policies\\Microsoft\\MicrosoftEdge',
'Software\\Policies\\Microsoft\\Windows Defender', 'Software\\Policies\\Microsoft\\WindowsFirewall',
'Software\\Policies\\Microsoft\\FVE', 'System\\CurrentControlSet\\Policies\\Microsoft\\FVE',
'Software\\Policies\\Microsoft\\Windows\\SrpV2', 'Software\\Policies\\Microsoft\\Windows\\Safer',
'Software\\Policies\\Microsoft\\Windows\\DeviceGuard', 'Software\\Policies\\Microsoft\\Windows\\DeviceInstall',
'Software\\Policies\\Microsoft\\Windows NT\\Printers', 'System\\CurrentControlSet\\Control\\Print',
'Software\\Policies\\Microsoft\\Windows NT\\Terminal Services', 'Software\\Policies\\Microsoft\\Windows\\WinRM',
'Software\\Policies\\Microsoft\\Windows\\LanmanServer', 'Software\\Policies\\Microsoft\\Windows\\LanmanWorkstation',
'System\\CurrentControlSet\\Services\\LanmanServer', 'System\\CurrentControlSet\\Services\\LanmanWorkstation',
'System\\CurrentControlSet\\Services\\MrxSmb10', 'System\\CurrentControlSet\\Services\\Netbt',
'System\\CurrentControlSet\\Services\\Tcpip', 'System\\CurrentControlSet\\Services\\Tcpip6',
'Software\\Policies\\Microsoft\\Windows NT\\DNSClient', 'Software\\Policies\\Microsoft\\Windows\\Installer'
].map(key => [key, 'hardening'] as const)
]
}],
fallback: { domain: 'hardening', reason: 'Other GPO settings configure system hardening.' }
};

View File

@ -27,11 +27,11 @@ export const maesterDefinition: ToolProfileDefinition = {
diagnosticPolicy: 'fail-closed-by-impact'
},
name: 'Maester',
version: '1.1.0',
version: '1.2.0',
processing: {
mappingVersion: '1.1.0', normalizationVersion: '1.0.0', presentationVersion: '1.1.0',
compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible',
reimportRequired: true, affectedDimensions: ['evidence', 'environment-context', 'presentation']
reimportRequired: true, affectedDimensions: ['environment-context', 'recognition']
},
sourceSignals: [
{ scope: 'file-name', pattern: /maester|test[\s_.-]*results?/i, contribution: 0.25, message: 'Der Dateiname entspricht einem Maester-Testexport.' },

View File

@ -0,0 +1,56 @@
import type { SecurityDomainMapping } from '../security-domain-mapping';
/**
* Maester: the scanner's own tags and block first, then the suite/control ID.
* Decision 2026-10-04: Entra checks (EIDSCA, CISA MS.AAD, Maester/Entra, Conditional
* Access, MFA, Identity Protection) are Identity & Authentication; Exchange, Teams,
* SharePoint, ORCA, Intune and other tenant configuration are Cloud & Collaboration.
* A native `Privileged`/`PIM` tag marks privileged access.
*/
export const maesterSecurityDomains: SecurityDomainMapping = {
version: '1.0.0',
profileId: 'maester',
toolNames: ['Maester', 'maester'],
ruleSets: [{
tier: 1, method: 'native-tag', field: ['Tags', 'Tag'], match: 'list-item',
entries: [['Privileged', 'privilege'], ['PIM', 'privilege'], ['Backup', 'recovery'], ['Devices', 'hardening']]
}, {
tier: 3, method: 'suite-control-id', field: ['Source Rule ID', 'ID'], match: 'prefix',
entries: [
['EIDSCA.', 'identity'],
['CISA.MS.AAD.7.', 'privilege'],
['CISA.MS.AAD.', 'identity'],
['CISA.MS.EXO.17.', 'recovery'],
['CISA.MS.DEFENDER.6.', 'recovery'],
['CISA.MS.', 'cloud'],
['ORCA.', 'cloud'],
['CIS.M365.1.1.', 'privilege'],
['CIS.M365.3.1.', 'recovery'],
['CIS.M365.5.', 'identity'],
['CIS.M365.', 'cloud']
]
}, {
tier: 1, method: 'native-category', field: ['Category', 'Block'], match: 'exact',
entries: [
['Maester/Entra', 'identity'],
['Identity Protection', 'identity'],
['Applications', 'identity'],
['EIDSCA', 'identity'],
['Maester/Exchange', 'cloud'],
['Maester/Teams', 'cloud'],
['Maester/Intune', 'cloud'],
['AzureConfig', 'cloud'],
['ORCA', 'cloud'],
['Exposure Management', 'cloud'],
['Defender for Identity health issues', 'recovery']
]
}, {
// Maester's own topical tags when the block is the generic `Maester`.
tier: 1, method: 'native-tag', field: ['Tags', 'Tag'], match: 'list-item',
entries: [
['CA', 'identity'], ['Authentication', 'identity'], ['Maester/Entra', 'identity'], ['Entra', 'identity'],
['Exchange', 'cloud'], ['EXO', 'cloud'], ['Teams', 'cloud'], ['SharePoint', 'cloud'], ['Intune', 'cloud'], ['Azure', 'cloud']
]
}],
fallback: { domain: 'cloud', reason: 'Maester assesses Microsoft 365 and Entra tenant configuration.' }
};

View File

@ -0,0 +1,60 @@
import type { SecurityDomainMapping } from '../security-domain-mapping';
/**
* PingCastle: exact native rule IDs first, because the report category alone (Anomalies,
* Stale Objects) spans several domains; the native category is the fallback within the
* profile. The rule ID is always retained, so no reimport is needed.
*/
export const pingCastleSecurityDomains: SecurityDomainMapping = {
version: '1.0.0',
profileId: 'pingcastle',
toolNames: ['PingCastle', 'pingcastle'],
ruleSets: [{
tier: 1, method: 'native-rule-id', field: 'Source Rule ID', match: 'exact',
entries: [
// Identity & Authentication
['S-OldNtlm', 'identity'], ['S-PwdNeverExpires', 'identity'], ['S-KerberosArmoring', 'identity'],
['S-KerberosArmoringDC', 'identity'], ['S-Inactive', 'identity'], ['S-PwdNotRequired', 'identity'],
['S-Reversible', 'identity'], ['S-DesEnabled', 'identity'], ['S-PrimaryGroup', 'identity'],
['S-NoPreAuth', 'identity'], ['S-NoPreAuthAdmin', 'identity'], ['S-SIDHistory', 'identity'],
['S-C-Inactive', 'identity'], ['S-C-PrimaryGroup', 'identity'], ['S-C-Reversible', 'identity'],
['A-MinPwdLen', 'identity'], ['A-NoServicePolicy', 'identity'], ['A-Krbtgt', 'identity'],
['A-LMHashAuthorized', 'identity'], ['A-SmartCardRequired', 'identity'], ['A-ReversiblePwd', 'identity'],
// Privileged Access & Delegation
['S-ADRegistration', 'privilege'], ['A-AdminSDHolder', 'privilege'], ['A-LAPS-Not-Installed', 'privilege'],
['A-LAPS-Joined-Computers', 'privilege'], ['A-UnconstrainedDelegation', 'privilege'],
['P-Delegated', 'privilege'], ['P-AdminLogin', 'privilege'], ['P-ServiceDomainAdmin', 'privilege'],
['P-SchemaAdmin', 'privilege'], ['P-ProtectedUsers', 'privilege'],
// AD Infrastructure & PKI
['S-DC-SubnetMissing', 'infrastructure'], ['A-CertTempCustomSubject', 'infrastructure'],
['A-WeakRSARootCert', 'infrastructure'], ['A-WeakRSARootCert2', 'infrastructure'], ['A-CertROCA', 'infrastructure'],
['A-MD5RootCert', 'infrastructure'], ['A-SHA1RootCert', 'infrastructure'], ['A-NotEnoughDC', 'infrastructure'],
['A-PreWin2000Other', 'infrastructure'], ['A-PreWin2000AuthenticatedUsers', 'infrastructure'],
['A-PreWin2000Anonymous', 'infrastructure'], ['A-RootDseAnonBinding', 'infrastructure'],
['A-DsHeuristicsLDAPSecurity', 'infrastructure'], ['A-DsHeuristicsAnonymous', 'infrastructure'],
['A-DnsZoneAUCreateChild', 'infrastructure'], ['A-DnsZoneUpdate1', 'infrastructure'], ['A-DnsZoneUpdate2', 'infrastructure'],
['A-DCLdapsProtocolAdvanced', 'infrastructure'], ['A-DCLdapsProtocol', 'infrastructure'],
['A-DCLdapSign', 'infrastructure'], ['A-DCLdapsChannelBinding', 'infrastructure'], ['P-UnprotectedOU', 'infrastructure'],
// System Hardening
['A-DC-Spooler', 'hardening'], ['A-HardenedPaths', 'hardening'], ['A-NoNetSessionHardening', 'hardening'],
['A-NoGPOLLMNR', 'hardening'], ['S-FolderOptions', 'hardening'], ['S-DefenderASR', 'hardening'],
['S-FirewallScript', 'hardening'], ['S-TerminalServicesGPO', 'hardening'], ['S-SMB-v1', 'hardening'],
['S-DC-NotUpdated', 'hardening'], ['S-OS-NT', 'hardening'], ['S-OS-2000', 'hardening'], ['S-OS-XP', 'hardening'],
['S-OS-Vista', 'hardening'], ['S-OS-W7', 'hardening'], ['S-OS-W8', 'hardening'], ['S-OS-W10', 'hardening'],
['S-OS-2003', 'hardening'], ['S-OS-2008', 'hardening'], ['S-OS-2012', 'hardening'], ['S-DC-2000', 'hardening'],
['S-DC-2003', 'hardening'], ['S-DC-2008', 'hardening'], ['S-DC-2012', 'hardening'],
// Monitoring & Recovery
['A-BackupMetadata', 'recovery'], ['A-AuditDC', 'recovery'], ['A-AuditPowershell', 'recovery'],
['P-RecycleBin', 'recovery']
]
}, {
tier: 1, method: 'native-category', field: 'Category', match: 'exact',
entries: [
['Privileged Accounts', 'privilege'],
['Trusts', 'infrastructure'],
['Stale Objects', 'identity'],
['Anomalies', 'hardening']
]
}],
fallback: { domain: 'infrastructure', reason: 'PingCastle assesses the Active Directory domain as a whole.' }
};

View File

@ -0,0 +1,25 @@
import type { SecurityDomainMapping } from '../security-domain-mapping';
/** Purple Knight's own indicator categories (Raw Data `Category`). Decision 2026-10-04: Group Policy Security is System Hardening. */
export const purpleKnightSecurityDomains: SecurityDomainMapping = {
version: '1.0.0',
profileId: 'purple-knight',
toolNames: ['Purple Knight', 'PurpleKnight', 'purple-knight'],
ruleSets: [{
tier: 1, method: 'native-category', field: 'Category', match: 'exact',
entries: [
['Account Security', 'identity'],
['Kerberos Security', 'identity'],
['Hybrid (AD)', 'identity'],
['Hybrid', 'identity'],
['AD Delegation', 'privilege'],
['AD Infrastructure Security', 'infrastructure'],
['Group Policy Security', 'hardening']
]
}],
fallback: {
domain: 'infrastructure',
reason: 'Purple Knight assesses Active Directory as a whole.',
byField: { field: 'Target', entries: [['AAD', 'identity'], ['Entra ID', 'identity'], ['Hybrid (AD)', 'identity']] }
}
};

View File

@ -0,0 +1,49 @@
/**
* Declarative Security Domain mapping of one scanner profile (user decision 2026-10-04).
*
* A mapping reads only fields that Raw Data shows for a Work record and the scanner's own
* structure: native categories, exact rule IDs, native tags, report section titles, suite
* or control IDs and GPO policy identities. Titles, descriptions and remediation prose are
* never read. The assignment depends on the check only, never on its outcome.
*/
export type SecurityDomainId = 'identity' | 'privilege' | 'infrastructure' | 'hardening' | 'cloud' | 'recovery';
/** 1 native category or rule, 2 report section, 3 suite/control ID, 4 GPO policy identity, 5 source fallback. */
export type SecurityDomainTier = 1 | 2 | 3 | 4 | 5;
export type SecurityDomainMethod =
| 'native-category' | 'native-rule-id' | 'native-tag' | 'native-target'
| 'report-section' | 'suite-control-id' | 'policy-section' | 'registry-key';
export interface SecurityDomainRuleSet {
tier: Exclude<SecurityDomainTier, 5>;
method: SecurityDomainMethod;
/** Raw Data field label, compared case-insensitively; alternatives name the same native field in other report formats. */
field: string | ReadonlyArray<string>;
/**
* `exact`: the whole value equals a key. `prefix`: the longest key the value starts with
* (with `boundary`, only at that separator). `list-item`: one item of a comma/semicolon list
* equals a key; the first entry in declaration order wins. `section-path`: a ` > ` separated
* title path; every key segment must start the value segment at the same depth, and the
* longest matching key path wins.
*/
match: 'exact' | 'prefix' | 'list-item' | 'section-path';
boundary?: string;
/** Applies the rule set only when another Raw Data field has exactly this value. */
when?: { field: string; equals: string };
entries: ReadonlyArray<readonly [string, SecurityDomainId]>;
}
export interface SecurityDomainMapping {
version: string;
profileId: string;
/** Tool names as Raw Data shows them in `Source: Scan` before ` · `, and canonical tool IDs. */
toolNames: ReadonlyArray<string>;
ruleSets: ReadonlyArray<SecurityDomainRuleSet>;
/** Tier 5: the area the source checks as a whole, optionally refined by an exact field value. */
fallback: {
domain: SecurityDomainId;
reason: string;
byField?: { field: string; entries: ReadonlyArray<readonly [string, SecurityDomainId]> };
};
}

View File

@ -0,0 +1,160 @@
# Approved ASSESSOR Management Report template
Status: binding user-approved template, confirmed 2026-10-02.
The implemented template is `context-management-report.ts`, delivered through
`management-overview-report.ts` (renderer 3.2.3). This implementation, rather than
a copied historical HTML export, is the reference for future Management Reports.
The user approved its layout and requested that it be retained inside Report Engine.
Future changes must preserve this agreement unless the user explicitly revises it.
## Visual contract
- White report sheets; existing ASSESSOR logo and wordmark in header and footer.
- Orange headings, dark table headers, alternating light table rows, subtle separators.
- Two-column explanatory and evidence sections; compact factual tables and bar charts.
- Embedded existing Inter font and ASSESSOR logo; no remote assets, scripts, LLM or Internet requests.
- Readable 16px prose and 14px structured data. Flat square heatmap tiles, no decorative effects.
- Footer includes product version, client name and logical report-sheet numbering.
- Explicit correction 2026-10-04: every sheet carries **Streng vertraulich** in its
header and footer. **Management** occurs only in the header's report label.
Body headings use Summary and Themen; no scanner names, scanner-origin
tables, scanner citations or source-composition/confidence breakdowns are shown.
- Responsive single-column reading and existing A4 print rules; logical sheet numbers
do not assert physical PDF page numbers.
This is the explicit export-only style exception based on the user-supplied
Huntsman report reference. It does not change the Core application's design language.
## Content sequence
1. Summary: assessment overview, published counts, populated themes,
the authoritative ASSESSOR Overall Score as a full-circle donut, recorded work decisions,
and comparison only where a comparison snapshot exists.
Explicit correction 2026-10-04: both first-sheet summary labels read Summary;
Handlungsfelder is a heading followed by bullet points for the evidenced themes.
The introductory prose uses Angaben instead of Blätter.
Omit the technical sentence about findings without a cluster assignment from Summary.
2. Theme overview: six themes and **Möglicher Business Impact**, with one simple
finding count per theme and a matching horizontal bar chart. No impact rating,
category-composition explanation in the count column or financial amount.
Explicit correction 2026-10-04: omit the technical note beneath this overview
about overlapping findings, summed counts and absent evidence. Counting semantics
remain unchanged; areas without evidence still state no derivable impact.
Omit the explanatory count/overlap note beneath the theme bar chart as well.
Omit the sentence about automatic assignments and possible consequences being
an approach requiring specialist review from the theme sheets.
3. One sheet for each theme: Betriebsfähigkeit, Finanzielle Auswirkungen,
Informationen & Daten, Kontrolle & Vertrauen, Compliance & Haftung,
Widerstandsfähigkeit & Wiederanlauf. Each shows possible impact, observation,
action, evidence counts and limits. Evidence tables use Problemfeld, Beobachteter
Zusammenhang and Anzahl, without scanner/GPO or confidence breakdowns. Themes without mapped
evidence explicitly state that no impact statement is derivable.
4. Complete Context heatmap: category Base Severity, category name and finding count;
unique overall finding count, neutral categories without evidence, overlap explanation
and overlap explanation.
5. Scope and method appendix: available environment facts and observation times,
technical domain counts, Context categories without a business-impact mapping,
source limitations and version provenance.
The explicit user decision 2026-10-04 supersedes the earlier category-first content
sequence and the prohibition on any business-consequence wording only for clearly
labelled **possible** consequences. It does not authorize actual client-impact,
exploitability, compliance verdicts, impact grades or monetary estimates. Governance
is addressed through recorded decisions and action guidance across the themes.
The later explicit screenshot correction 2026-10-04 supersedes the earlier source
coverage, source-count chart, printed source attribution and confidence-detail
requirements for this report's visible content. Retained report projection,
provenance and technical evidence are unchanged. The overall-score diagram reads
`projection.overallScore` directly, with no new calculation. Zero is valid; missing
or invalid scores are explicitly unavailable. Theme bars show finding counts only,
never a business-risk rating. Both charts remain entirely offline SVG/HTML/CSS.
## Text revision 2026-10-04 (renderer 3.3.0, Textbausteine 1.0.0)
Explicit user request supersedes the generic per-row wording: every Context category
has a fixed German reading name with Sachverhalt, Einordnung and Ansatzpunkt in
`management-report-texts.ts`. Theme sheets open with a data-derived Kernaussage
(count, share, strongest category), the possible impact, an evidence table
(Problemfeld, Anzahl, Anteil) and one block per category ordered by count. Base
Severity stays out of theme sheets; it appears in the heatmap and as one factual
Summary sentence. The Summary derives its statements from published counts only:
deviation rate, Overall Score, largest themes, strongest categories, Critical
categories, the Security Domain with the highest deviation rate and unexecuted
checks. General disclaimers are stated once under Lesehinweise und Aussagegrenzen;
observation times are summarised as count and date range. No scanner names,
owners, deadlines, stages or impact grades.
Layout correction 2026-10-04: every table outside the Summary spans the full sheet
width; theme evidence tables sit below the two-column text. Count and share columns
are narrow, right-aligned and never wrapped; text wraps only at word boundaries.
On phone widths wide tables scroll horizontally.
## Data and security contract
Explicit correction 2026-10-04 supersedes overlapping theme counts in the overview:
the table and Abweichungen nach Hauptthema chart form an exclusive partition of
the authoritative Work deviation population. A finding is counted once in its
primary theme using a versioned category-to-primary-theme mapping. Conflicting or
missing assignments appear as Nicht eindeutig zugeordnet. Out-of-scope keys never
enter named bars. Financial and compliance impacts remain possible downstream
consequences in the prose, without duplicating the finding count. Complete original
Context associations remain available in the evidence detail and technical report.
The displayed counts including the unassigned row must sum exactly to the stated total.
Decision 2026-10-04 (mapping 2.0.0) supersedes the six themes and the conflict case
above: four disjoint themes (Kontrolle & Vertrauen, Informationen & Daten,
Betriebsfähigkeit, Widerstandsfähigkeit & Wiederanlauf); every Context category belongs
to exactly one, and the Context Engine publishes exactly one category per finding.
Financial and compliance consequences are one prose note, never a counted theme.
Findings without a category appear as Ohne Problemkategorie.
Counting correction 2026-10-04: theme totals deduplicate published findingKeys,
using one scanner occurrence or rolled-up GPO scope/setting identity as the unit.
Technical resultIds remain evidence, never multiple findings for one GPO setting.
Historical publications without findingKeys retain their existing ID interpretation.
Summary totals use the shared Core workspace summary with current GPO evaluations,
matching Work's rollup, when available. Cross-theme membership is retained without
summing theme counts. Technical rows and historical evaluation evidence remain separate.
Consume the published Context results and the same frozen Core report projection.
Do not reclassify findings, reinterpret verdicts, invent metrics or sum overlapping
clusters into a total. Base Severity is category significance, not an enterprise score,
probability or proven exploit. Work status and risk acceptance do not prove remediation.
GPO evidence describes backup configuration, not effective host state.
Never fabricate time series, scores, affected endpoints, actual business consequences,
owners, deadlines or effort to fill the reference layout. Technical identifiers,
individual evidence and original recommendations remain in the separate Technical Report.
Core disclosure rules continue to govern all content. Historical archived reports
remain unchanged; this template is applied to newly generated reports.
Possible impact is generated entirely offline from a versioned Report Engine table
(`management-business-impact.ts`, 1.0.0), fixed German text and the published Context
results. No Internet calls, LLM, similarity matching or new Context assignment.
Per-theme counts deduplicate published result IDs; overlapping theme counts are not
summed. Unknown categories receive no implicit business-impact mapping. Context
Base Severity remains in its evidence appendix and never becomes an impact rating.
## Acceptance references
`tests/reporting/context-management-report.test.ts` verifies offline assets, layout
structure, counts, confidence, technical-evidence separation and input invariance.
The permanent Report Engine invariance and boundary checks remain applicable.
`tests/reporting/management-report-preview.ts` generates explicitly synthetic example
data through the production renderer; it is a preview, not the template's data source.
## Approved assignment labels (2026-10-02)
Historical decision; superseded for this report's visible confidence details by
the 2026-10-04 count-only correction above. Stored confidence values and historical
archived reports remain unchanged.
Use **Nach Katalog**, **Nach Befundmerkmalen** and **Thematisch eingeordnet**
consistently in the German Management Report's reading key, assignment-count tables
and summaries. Internal catalogued/recognized/estimated fields and their counts
remain unchanged. The thematic tier describes an automatic assignment based on
content clues and explicitly states that professional confirmation remains pending.
Do not use Geschätzt or geschätzt as report-authored wording. Historical archived
reports remain unchanged.

View File

@ -9,7 +9,11 @@ import {
} from './report-projection';
import type { UnifiedResultProjection } from '../domain/unified-result';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
import { securityDomainCatalog, securityDomainIdForUnifiedResult, unclassifiedSecurityDomain } from '../features/dashboard/security-domains';
import { securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../evaluation/security-domains';
import { summarizeWorkspaceResults } from '../features/project-import/datahub-scan-analysis';
import { profileForSheet } from '../features/project-import/workspace-profile';
import { dashboardResultState } from '../features/dashboard/dashboard-result-state';
import { managementWorkspaceDeviationKeys } from './management-business-impact';
function field(record: FindingRecord, label: string): string {
return record.fields.find(item => item.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'))?.value?.trim() ?? '';
@ -38,6 +42,7 @@ export interface ClientReportProjectionInput {
generatedAt: string;
results: UnifiedResultProjection[];
dataset: ImportedDataset;
workspaceDataset?: ImportedDataset;
environment?: EnvironmentSnapshot;
overallScore?: number;
scoreSources?: ReportScoreSource[];
@ -47,6 +52,11 @@ export interface ClientReportProjectionInput {
/** One frozen, filter-independent cut for both HTML files. */
export function clientReportInput(input: ClientReportProjectionInput): ReportProjectionInput {
const projectSheet = input.dataset.sheets.find(sheet => sheet.isProjectSheet);
const countDataset = input.workspaceDataset ?? input.dataset;
const countSheet = countDataset.sheets.find(sheet => sheet.isProjectSheet);
const counts = countSheet ? summarizeWorkspaceResults(countSheet.analysis.records, profileForSheet(countDataset, countSheet)) : undefined;
const deviationKeys = countSheet ? managementWorkspaceDeviationKeys(countSheet.analysis.records, record =>
dashboardResultState(record, profileForSheet(countDataset, countSheet)) === 'deviation') : undefined;
const policyRecords = (projectSheet?.analysis.records ?? []).filter(record => field(record, 'Category') === 'GPOs');
const policyEntries = policyRecords.flatMap(record => {
const entry = policyEntry(record);
@ -54,6 +64,8 @@ export function clientReportInput(input: ClientReportProjectionInput): ReportPro
});
const workRecordByOccurrence = new Map((projectSheet?.analysis.records ?? [])
.flatMap(record => record.findingOccurrenceId ? [[record.findingOccurrenceId, record] as const] : []));
const policyRecordByIdentity = new Map(policyRecords
.map(record => [`${field(record, 'Scope')}|${field(record, 'Technical Identity')}`, record] as const));
const reviewInputs = input.results.flatMap(result => {
const record = workRecordByOccurrence.get(result.sourceResultId);
const review = record && input.reviews?.[record.id];
@ -89,15 +101,19 @@ export function clientReportInput(input: ClientReportProjectionInput): ReportPro
project: { id: input.project.id, name: input.project.name },
assessmentId: input.assessmentIds?.length === 1 ? input.assessmentIds[0] : undefined,
results: input.results,
domains: [
...securityDomainCatalog.map(({ id, label }) => ({ id, label })),
...(input.results.some(result => securityDomainIdForUnifiedResult(result) === 'unclassified')
? [unclassifiedSecurityDomain] : [])
],
domainOf: securityDomainIdForUnifiedResult,
domains: securityDomainCatalog.map(({ id, label }) => ({ id, label })),
// The same classifier and Raw Data record as Work, so a check has one domain everywhere.
domainOf: result => {
const record = result.sourceKind.startsWith('policy-')
? policyRecordByIdentity.get(`${result.policyScope ?? ''}|${result.policyIdentityKey ?? ''}`)
: workRecordByOccurrence.get(result.sourceResultId);
return record ? securityDomainIdForRecord(record) : securityDomainIdForUnifiedResult(result);
},
priorityBases: reportPriorityBases(toolProfileDefinitions),
overallScore: input.overallScore,
scoreSources: input.scoreSources,
workspaceCounts: counts ? { deviations: counts.deviation, assessed: counts.deviation + counts.passed } : undefined,
workspaceDeviationKeys: deviationKeys,
environment,
policyEntries,
reviews: reviewInputs,

View File

@ -0,0 +1,170 @@
import type { ReportProjection } from './report-projection';
import { ReportContextRequiredError } from './report-context';
import { escapeHtml } from '../shared/html';
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { REPORT_INTER_FONT } from './report-font';
import { REPORT_ASSESSOR_LOGO } from './report-brand';
import { managementBusinessImpacts, managementFindingDistribution, MANAGEMENT_BUSINESS_IMPACT_VERSION, MANAGEMENT_DOWNSTREAM_CONSEQUENCES } from './management-business-impact';
import { managementCategoryTexts, MANAGEMENT_READING_NOTES, MANAGEMENT_REPORT_TEXTS_VERSION } from './management-report-texts';
const n = (value: number) => value.toLocaleString('de-DE');
const severity = { critical: 'Critical', high: 'High', medium: 'Medium', undetermined: 'Undetermined' };
/** Counts and shares are numeric columns: narrow, right-aligned and never wrapped. */
const numeric = (value: string) => /^(?:<?\d[\d.,]*(?: %)?|–)$/.test(value.trim());
const rows = (headers: string[], body: string[][]) => {
const numericColumn = headers.map((_header, index) => body.length > 0 && body.every(row => numeric(row[index] ?? '')));
const cell = (tag: 'th' | 'td', value: string, index: number) =>
`<${tag}${tag === 'th' ? ' scope="col"' : ''}${numericColumn[index] ? ' class="num"' : ''}>${escapeHtml(value)}</${tag}>`;
return `<table><thead><tr>${headers.map((item, index) => cell('th', item, index)).join('')}</tr></thead><tbody>${body.map(row => `<tr>${row.map((item, index) => cell('td', item, index)).join('')}</tr>`).join('')}</tbody></table>`;
};
const fact = (label: string, value: string) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`;
/** Render the authoritative ASSESSOR score; absence is not a zero score. */
function overallScoreChart(value: number | undefined): string {
const usable = typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100;
if (!usable) return '<figure class="chart overall-score"><figcaption>ASSESSOR Overall Score</figcaption><p>Kein Overall Score für diesen Assessment-Stand verfügbar.</p></figure>';
const label = value.toLocaleString('de-DE', { maximumFractionDigits: 1 });
return `<figure class="chart overall-score"><figcaption>ASSESSOR Overall Score</figcaption><svg viewBox="0 0 160 160" role="img" aria-label="ASSESSOR Overall Score: ${label} von 100"><circle class="score-track" cx="80" cy="80" r="60"/><circle class="score-value" cx="80" cy="80" r="60" pathLength="100" stroke-dasharray="${value} ${100 - value}" transform="rotate(-90 80 80)"/><text x="80" y="79" text-anchor="middle" class="score-number">${label}</text><text x="80" y="103" text-anchor="middle" class="score-scale">von 100</text></svg><p class="note">Technische ASSESSOR-Bewertung des Assessment-Stands; keine Bewertung des Business Impacts.</p></figure>`;
}
/** Counts only: each bar uses its stated count, never a synthesized risk or score. */
function bars(title: string, values: Array<[string, number]>): string {
const max = Math.max(1, ...values.map(([, value]) => value));
return `<figure class="chart"><figcaption>${escapeHtml(title)}</figcaption><div class="bar-chart">${values.map(([label, value]) => `<div class="bar-row"><span>${escapeHtml(label)}</span><div class="bar-track"><i style="width:${Math.max(0, value) / max * 100}%" aria-hidden="true"></i></div><strong>${n(value)}</strong></div>`).join('')}</div></figure>`;
}
/** Share as a whole percentage; a positive share below one percent never reads as zero. */
const share = (part: number, whole: number) => {
if (!whole) return '0 %';
const value = part / whole * 100;
return part > 0 && value < 0.5 ? '<1 %' : `${Math.round(value).toLocaleString('de-DE')} %`;
};
const list = (items: string[]) => items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} und ${items.at(-1)}`;
const deviationsWord = (value: number) => `${n(value)} ${value === 1 ? 'Abweichung' : 'Abweichungen'}`;
const dateDe = (value: string) => new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' });
type PublishedCluster = NonNullable<ReportProjection['context']>['clusters'][number];
const textOf = (cluster: PublishedCluster) => managementCategoryTexts[cluster.id]
?? { name: cluster.label, situation: cluster.description, significance: '', lever: '' };
const countOf = (cluster: PublishedCluster) => cluster.scanner + cluster.gpo;
/** Theme sheets order by evidence count; Base Severity stays in the heatmap and never becomes an impact rating. */
const byCount = (left: PublishedCluster, right: PublishedCluster) => countOf(right) - countOf(left) || left.label.localeCompare(right.label);
/** One considered block per category: what it concerns, what it favours, where the recommendations start. */
function categoryBlock(cluster: PublishedCluster): string {
const text = textOf(cluster);
return `<section class="category"><h3>${escapeHtml(text.name)}</h3>
<p class="meta">${escapeHtml(cluster.label)} · ${escapeHtml(deviationsWord(countOf(cluster)))}</p>
<p><strong>Sachverhalt.</strong> ${escapeHtml(text.situation)}</p>
${text.significance ? `<p><strong>Einordnung.</strong> ${escapeHtml(text.significance)}</p>` : ''}
${text.lever ? `<p><strong>Ansatzpunkt.</strong> ${escapeHtml(text.lever)}</p>` : ''}</section>`;
}
/** Offline, paginated management cut; no raw technical identifiers or inferred business score. */
export function buildContextManagementHtml(projection: ReportProjection): string {
const context = projection.context;
if (!context) throw new ReportContextRequiredError();
const active = context.clusters.filter(item => countOf(item) > 0);
const classified = active.filter(item => item.id !== 'other-deviations');
const impacts = managementBusinessImpacts(context);
// Read the same published workspace summary as Work; technical rows are evidence only.
const deviations = projection.workspaceCounts?.deviations ?? projection.coverage.deviations;
const assessed = projection.workspaceCounts?.assessed ?? projection.coverage.assessed;
const distribution = managementFindingDistribution(context, projection.workspaceCounts?.deviations ?? context.findingCount, projection.workspaceDeviationKeys);
const themeCount = (id: string) => distribution.themes.find(theme => theme.id === id)!.count;
const distributionRows: Array<[string, number]> = distribution.themes.map(item => [item.label, item.count]);
if (distribution.unassigned) distributionRows.push(['Ohne Problemkategorie', distribution.unassigned]);
const reviewed = projection.workItems.filter(item => item.status && item.status !== 'open');
const accepted = projection.remediation?.riskAccepted ?? 0;
const date = new Date(projection.generatedAt).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' });
const sheets: Array<{ title: string; subtitle: string; body: string }> = [];
// Summary statements derive only from published counts; nothing is rated or extrapolated.
const ranked = [...distribution.themes].filter(item => item.count > 0).sort((left, right) => right.count - left.count);
const critical = classified.filter(item => item.severity === 'critical');
const criticalCount = critical.reduce((sum, item) => sum + countOf(item), 0);
const topCategories = [...classified].sort((left, right) => countOf(right) - countOf(left) || left.label.localeCompare(right.label)).slice(0, 3);
const domainRates = projection.domains.filter(item => item.assessed > 0)
.sort((left, right) => right.deviations / right.assessed - left.deviations / left.assessed || right.deviations - left.deviations);
const score = projection.overallScore;
const scoreText = typeof score === 'number' && Number.isFinite(score) && score >= 0 && score <= 100
? ` Der ASSESSOR Overall Score liegt bei ${score.toLocaleString('de-DE', { maximumFractionDigits: 1 })} von 100.` : '';
const summary = [
`Von ${n(assessed)} bewerteten Ergebnissen weichen ${n(deviations)} von der jeweiligen Empfehlung ab; das entspricht ${share(deviations, assessed)}.${scoreText}`,
ranked.length ? `ASSESSOR ordnet die Abweichungen ${n(classified.length)} Problemfeldern und darüber vier Themen zu. Den größten Anteil hat ${ranked[0].label} mit ${deviationsWord(ranked[0].count)} (${share(ranked[0].count, distribution.total)})${ranked[1] ? `, gefolgt von ${ranked[1].label} mit ${n(ranked[1].count)}` : ''}.` : 'Den Abweichungen ist im vorliegenden Assessment-Stand kein Problemfeld zugeordnet.',
topCategories.length > 1 ? `Am stärksten belegt sind die Problemfelder ${list(topCategories.map(item => `${textOf(item).name} (${n(countOf(item))})`))}.` : '',
critical.length ? `${critical.length === 1 ? 'Im Problemfeld' : 'In den Problemfeldern'} mit der Grundschwere Critical – ${list(critical.map(item => textOf(item).name))} – ${criticalCount === 1 ? 'liegt' : 'liegen'} zusammen ${deviationsWord(criticalCount)} vor.` : 'Keinem Problemfeld mit der Grundschwere Critical ist eine Abweichung zugeordnet.',
domainRates.length ? `Nach Security Domains zeigt ${domainRates[0].domainLabel} die höchste Abweichungsquote (${n(domainRates[0].deviations)} von ${n(domainRates[0].assessed)}, ${share(domainRates[0].deviations, domainRates[0].assessed)}).` : '',
projection.coverage.notRun || projection.coverage.executionErrors
? `${n(projection.coverage.notRun)} Prüfungen wurden nicht ausgeführt${projection.coverage.executionErrors ? `, ${n(projection.coverage.executionErrors)} endeten mit einem Ausführungsfehler` : ''}. Ihr Ergebnis ist offen und nicht als bestanden gewertet.` : ''
].filter(Boolean);
sheets.push({ title: 'Security Assessment', subtitle: 'Summary', body: `
<div class="two-column"><div><h2>Summary</h2>
${summary.map(sentence => `<p>${escapeHtml(sentence)}</p>`).join('')}
<section class="action-fields"><h3>Handlungsfelder</h3>${ranked.length ? `<ul>${ranked.map(item => `<li>${escapeHtml(item.label)} – ${escapeHtml(deviationsWord(item.count))}</li>`).join('')}</ul>` : '<p>Keine Business-Impact-Zuordnung aus den vorhandenen Kategorien ableitbar.</p>'}</section>
<p>Die folgenden Angaben beschreiben je Thema den möglichen Business Impact, die betroffenen Problemfelder und deren Ansatzpunkte. Originalempfehlungen und Einzelnachweise stehen im technischen Report desselben Assessment-Stands.</p></div>
<div><h2>Assessment auf einen Blick</h2>${overallScoreChart(projection.overallScore)}${rows(['Kennzahl', 'Wert'], [
['Bewertete Ergebnisse', n(assessed)], ['Abweichungen', n(deviations)], ['Abweichungsquote', share(deviations, assessed)],
['Belegte Problemfelder', n(classified.length)], ['Nicht ausgeführte Prüfungen', n(projection.coverage.notRun)],
['Ausführungsfehler', n(projection.coverage.executionErrors)]])}</div></div>
<div class="two-column"><div><h2>Bearbeitungsstand und Entscheidungen</h2><p>${accepted ? `Für ${n(accepted)} ${accepted === 1 ? 'Abweichung ist' : 'Abweichungen ist'} eine Risikoakzeptanz dokumentiert.` : 'Für keine Abweichung ist eine Risikoakzeptanz dokumentiert.'} ${reviewed.length ? `Für ${n(reviewed.length)} ${reviewed.length === 1 ? 'Abweichung ist' : 'Abweichungen ist'} ein Bearbeitungsstatus erfasst.` : 'Ein Bearbeitungsstatus ist nicht erfasst.'}</p><p class="note">Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung. Behebung erfordert einen vergleichbaren Rescan.</p></div>
<div><h2>Veränderung zum Vergleichsstand</h2>${projection.delta ? rows(['Vergleichsstatus', 'Anzahl'], Object.entries(projection.delta.counts).map(([state, value]) => [state, n(value)])) : '<p>Kein belegter Assessment-Vergleich in diesem Report-Snapshot vorhanden; eine zeitliche Verbesserung oder Verschlechterung wird nicht behauptet.</p>'}</div></div>` });
sheets.push({ title: 'Möglicher Business Impact', subtitle: 'Themen im Überblick', body: `
<p>Jede Abweichung ist über ihr Problemfeld genau einem von vier Themen zugeordnet. Jedes Thema steht für eine Leitfrage aus Unternehmenssicht; die Zuordnung beschreibt mögliche Folgen und bewertet weder Ausmaß noch Wahrscheinlichkeit.</p>
<div class="business-overview">${rows(['Thema', 'Leitfrage', 'Anzahl', 'Anteil'], impacts.map(item => [item.label,
item.clusters.length ? item.question : 'Aus den vorhandenen Context-Kategorien ist hierzu keine Aussage ableitbar.',
n(themeCount(item.id)), share(themeCount(item.id), distribution.total)]).concat(distribution.unassigned
? [['Ohne Problemkategorie', 'Abweichungen ohne belegtes Problemfeld; im technischen Report vollständig enthalten.', n(distribution.unassigned), share(distribution.unassigned, distribution.total)]] : []))}</div>
${bars('Abweichungen nach Hauptthema', distributionRows)}
<p><strong>Gesamt: ${n(distribution.total)} Abweichungen</strong></p>
<p class="note">${escapeHtml(MANAGEMENT_DOWNSTREAM_CONSEQUENCES)}</p>
` });
for (const item of impacts) {
const count = themeCount(item.id);
const clusters = [...item.clusters].sort(byCount);
const strongest = clusters[0];
const statement = clusters.length ? [
`${deviationsWord(count)} von ${n(distribution.total)} (${share(count, distribution.total)}) gehören zu diesem Thema; sie verteilen sich auf ${n(clusters.length)} ${clusters.length === 1 ? 'Problemfeld' : 'Problemfelder'}.`,
clusters.length > 1 ? `Am stärksten belegt ist ${textOf(strongest).name} mit ${deviationsWord(countOf(strongest))} (${share(countOf(strongest), count)} des Themas).` : ''
].filter(Boolean) : ['Diesem Thema ist im vorliegenden Assessment-Stand keine Abweichung zugeordnet.'];
sheets.push({ title: item.label, subtitle: item.question, body: `
<article class="business-impact"><div class="two-column"><div><h2>Kernaussage</h2>${statement.map(sentence => `<p>${escapeHtml(sentence)}</p>`).join('')}
<h2>Möglicher Business Impact</h2><p>${escapeHtml(item.clusters.length ? item.impact : 'Aus den vorhandenen Context-Kategorien ist hierzu keine Aussage ableitbar.')}</p></div>
<div><h2>Handlungsansatz</h2><p>${escapeHtml(item.clusters.length ? item.action : 'Die Datenabdeckung dieses Themas prüfen und fehlende fachliche Informationen mit den Verantwortlichen klären.')}</p>
<h2>Aussagegrenzen</h2><p>${escapeHtml(item.limitation)}</p></div></div>
<h2>Belege</h2>${clusters.length ? rows(['Problemfeld', 'Anteil', 'Anzahl'], clusters.map(cluster => [textOf(cluster).name, share(countOf(cluster), count), n(countOf(cluster))])) : '<p>Keine zugeordneten Belege im Report-Snapshot.</p>'}
${clusters.length ? `<h2>Problemfelder</h2>${clusters.map(categoryBlock).join('')}` : ''}</article>` });
}
sheets.push({ title: 'Context-Heatmap', subtitle: 'Problemkategorien im Überblick', body: `
<p>${deviationsWord(context.findingCount)} in ${n(classified.length)} belegten Problemfeldern. Jede Abweichung zählt in genau einem Problemfeld; die Farbe zeigt dessen Grundschwere.</p>
<ul class="heatmap">${context.clusters.map(item => `<li class="${item.hasEvidence ? item.severity : 'neutral'}"><span>Base Severity · ${severity[item.severity]}</span><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(deviationsWord(countOf(item)))}</span></li>`).join('')}</ul>
<p class="note">Critical · High · Medium · grau ohne Abweichungen oder mit unbestimmter Grundschwere. Neutral bedeutet nicht sicher: Ein graues Problemfeld ist nicht automatisch unauffällig. Erhobene Konfigurationen und beobachtete Zustände sind unterschiedliche Belegarten.</p>
${context.sharedSettingLevers.length ? `<p>${n(context.sharedSettingLevers.length)} ${context.sharedSettingLevers.length === 1 ? 'Konfigurationseinstellung erfüllt' : 'Konfigurationseinstellungen erfüllen'} Bedingungen mehrerer Problemfelder. Dies sind gemeinsame Prüfansatzpunkte, keine zugesicherte Wirkung oder Aufwandsbewertung.</p>` : ''}` });
const observed = projection.provenance.map(item => item.observedAt).filter((value): value is string => Boolean(value)).sort();
const unmapped = active.filter(item => item.id !== 'other-deviations' && !impacts.some(theme => theme.clusters.some(cluster => cluster.id === item.id)));
sheets.push({ title: 'Untersuchungsumfang', subtitle: 'Datenbasis und Nachvollziehbarkeit', body: `
<h2>Untersuchungsumfang und Datenbasis</h2><dl class="facts">${projection.environment.map(item => fact(item.key, item.value ?? 'Unbekannt')).join('')}</dl>
<p>${observed.length ? observed.length === 1 ? `Grundlage ist eine Erhebung vom ${dateDe(observed[0])}.`
: `Grundlage sind ${n(observed.length)} Erhebungen zwischen dem ${dateDe(observed[0])} und dem ${dateDe(observed.at(-1)!)}.` : 'Keine Erhebungszeitpunkte in diesem Snapshot vorhanden.'}
Nicht anwendbar: ${n(projection.coverage.notApplicable)} · sonstige unbewertete Ergebnisse: ${n(projection.coverage.unknown)}.</p>
${unmapped.length ? `<h2>Offene fachliche Zuordnung</h2>${rows(['Context-Kategorie', 'Findings', 'Einordnung'], unmapped.map(item => [item.label, n(countOf(item)), 'Keine Business-Impact-Zuordnung; fachlich prüfen.']))}` : ''}
<h2>Technische Domain-Auswertung</h2>${rows(['Security Domain', 'Bewertet', 'Abweichend', 'Bestanden', 'Abweichungsquote'], projection.domains.map(item => [item.domainLabel, n(item.assessed), n(item.deviations), n(item.passed), item.assessed ? share(item.deviations, item.assessed) : '–']))}<p class="note">Diese Zahlen beschreiben technische Ergebniszeilen. Sie sind keine Summen der Problemfelder.</p>
<h2>Lesehinweise und Aussagegrenzen</h2><ul class="reading-notes">${MANAGEMENT_READING_NOTES.map(note => `<li>${escapeHtml(note)}</li>`).join('')}</ul>
<h2>Nachvollziehbarkeit</h2><p>Der separate technische Report enthält die Belege und Originalempfehlungen. Beide Dateien wurden aus demselben gespeicherten Assessment-Stand erstellt.</p>
<p class="note">ASSESSOR Einordnung ${escapeHtml(MANAGEMENT_BUSINESS_IMPACT_VERSION)} · Textbausteine ${escapeHtml(MANAGEMENT_REPORT_TEXTS_VERSION)} · Context Engine ${escapeHtml(context.engineVersion)} · Katalog ${escapeHtml(context.catalogVersion)}. Offline erzeugt, ohne LLM und ohne Internetabruf. Aussagen verwenden feste Textbausteine und gespeicherte Ergebnisse. Tatsächliche Geschäftsfolgen, Ausnutzung, Kosten, Verantwortliche und Termine werden nicht behauptet.</p>` });
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ASSESSOR · Report · ${escapeHtml(projection.project.name)}</title>
<style>
@font-face{font-family:Inter;src:url('${REPORT_INTER_FONT}') format('woff2');font-weight:400 700;font-style:normal;font-display:swap}
:root{color-scheme:light;--ink:#153b50;--muted:#52636c;--surface:#edf3f5;--line:#a9b9c1;--accent:#ef6c00;--critical:#df174e;--high:#e96f00;--medium:#c5a000;--neutral:#e5e8eb}
*{box-sizing:border-box}body{margin:0;background:#e5e8eb;color:var(--ink);font-family:Inter,Arial,sans-serif;font-size:16px;line-height:1.5}.report-sheet{max-width:1040px;margin:24px auto;padding:20px;background:white;break-after:page}.report-sheet:last-child{break-after:auto}.masthead{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding-bottom:10px;font-size:14px}.brand{display:flex;gap:10px;align-items:center;font-weight:700}.brand img{width:40px;height:40px;object-fit:contain}.title-block{padding:20px 0 12px;border-bottom:1px solid var(--line);margin-bottom:20px}h1{font-size:32px;font-weight:600;line-height:1.2;color:var(--accent);margin:0 0 12px;overflow-wrap:break-word}.title-meta{display:flex;justify-content:space-between;gap:12px;font-size:14px;flex-wrap:wrap}h2{font-size:18px;font-weight:600;color:var(--accent);margin:20px 0 8px}h3{font-size:16px;font-weight:600;margin:16px 0 8px}p{margin:8px 0}.two-column{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start}.two-column h2:first-child{margin-top:0}.two-column>div{min-width:0}.note{font-size:14px;color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px;margin:12px 0}th,td{padding:8px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);overflow-wrap:break-word;hyphens:manual}th.num,td.num{width:1%;white-space:nowrap;text-align:right;font-variant-numeric:tabular-nums}th{font-weight:600;background:var(--ink);color:white}tbody tr:nth-child(even){background:var(--surface)}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;background:var(--line)}.facts>div{padding:12px;background:var(--surface)}dt{font-size:14px;color:var(--muted)}dd{margin:4px 0 0;font-size:16px;overflow-wrap:break-word}.heatmap{list-style:none;margin:16px 0;padding:0;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.heatmap li{padding:12px;min-height:126px;background:var(--neutral);break-inside:avoid}.heatmap .critical{background:var(--critical);color:white}.heatmap .high{background:var(--high);color:#171717}.heatmap .medium{background:var(--medium);color:#171717}.heatmap span{display:block;font-size:14px}.heatmap strong{display:block;margin:6px 0;font-weight:600;overflow-wrap:break-word}.severity-callout{border-left:6px solid var(--line);padding:12px;background:var(--surface);display:flex;align-items:center;justify-content:space-between;gap:12px}.severity-callout.critical{border-color:var(--critical)}.severity-callout.high{border-color:var(--high)}.severity-callout.medium{border-color:var(--medium)}.chart{border:1px solid var(--line);padding:16px;margin:20px 0}.chart figcaption{font-size:18px;font-weight:600;color:var(--accent);margin-bottom:16px}.bar-row{display:grid;grid-template-columns:minmax(130px,1fr) 2fr 65px;gap:12px;align-items:center;margin:12px 0;font-size:14px}.bar-row strong{font-weight:600;text-align:right}.bar-track{height:24px;background:var(--surface)}.bar-track i{display:block;height:100%;background:var(--accent)}footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;border-top:1px solid var(--line);padding-top:12px;margin-top:24px;font-size:14px;color:var(--muted)}footer .brand img{width:28px;height:28px}
.business-overview th:first-child{width:28%}
.category{border-top:1px solid var(--line);padding-top:8px;margin-top:16px;break-inside:avoid}.category h3{margin:0 0 4px;color:var(--ink)}.category .meta{font-size:14px;color:var(--muted);margin:0 0 8px}.reading-notes{margin:8px 0;padding-left:20px;font-size:14px}.reading-notes li{margin:4px 0}
.overall-score svg{display:block;width:220px;height:220px;margin:12px auto}.score-track,.score-value{fill:none;stroke-width:12}.score-track{stroke:var(--surface)}.score-value{stroke:var(--accent)}.score-number{fill:var(--ink);font-size:28px;font-weight:600}.score-scale{fill:var(--muted);font-size:14px}
@media(max-width:650px){table{display:block;overflow-x:auto}.report-sheet{margin:0;padding:20px}.two-column{grid-template-columns:1fr;gap:16px}.heatmap{grid-template-columns:repeat(2,minmax(0,1fr))}.bar-row{grid-template-columns:minmax(100px,1fr) 1fr 45px;gap:8px}.facts{grid-template-columns:1fr}h1{font-size:28px}}@page{size:A4;margin:12mm}@media print{body{background:white;print-color-adjust:exact;-webkit-print-color-adjust:exact}.report-sheet{margin:0;padding:0;max-width:none}.two-column{grid-template-columns:1fr 1fr}.heatmap{grid-template-columns:repeat(3,minmax(0,1fr))}.masthead,.title-block,footer,.severity-callout,.chart{break-inside:avoid}h1,h2,h3{break-after:avoid}thead{display:table-header-group}tr{break-inside:avoid}}
</style></head><body><main>${sheets.map((sheet, index) => `<section class="report-sheet" aria-label="${escapeHtml(sheet.title)}"><header class="masthead"><span class="brand"><img src="${REPORT_ASSESSOR_LOGO}" alt="ASSESSOR Logo">ASSESSOR</span><span>Management Report · Streng vertraulich</span></header><div class="title-block"><h1>${escapeHtml(sheet.title)}</h1><div class="title-meta"><span>${escapeHtml(sheet.subtitle)}</span><span>${escapeHtml(projection.project.name)} · ${escapeHtml(date)}</span></div></div>${sheet.body}<footer><span class="brand"><img src="${REPORT_ASSESSOR_LOGO}" alt="">ASSESSOR ${escapeHtml(ASSESSOR_PRODUCT_VERSION)}</span><span>${escapeHtml(projection.project.name)}</span><span>Streng vertraulich</span><span>Berichtsblatt ${index + 1} von ${sheets.length}</span></footer></section>`).join('')}</main></body></html>`;
}

View File

@ -0,0 +1,98 @@
import type { ReportContextPublication } from './report-context';
import type { FindingRecord } from '../domain/finding';
/** The same Work identities for report exports and the Client Info extension. */
export function managementWorkspaceDeviationKeys(records: readonly FindingRecord[], isDeviation: (record: FindingRecord) => boolean): string[] {
const field = (record: FindingRecord, label: string) => record.fields.find(item => item.label.toLowerCase() === label)?.value?.trim() ?? '';
return records.filter(isDeviation).map(record => record.findingOccurrenceId ? `scanner:${record.findingOccurrenceId}`
: field(record, 'category') === 'GPOs' ? `gpo:${field(record, 'scope').toLowerCase()}\u0000${field(record, 'technical identity')}`
: `unmapped:${record.id}`);
}
/** Report-only interpretation of published categories; no new finding assignment or impact rating. */
export const MANAGEMENT_BUSINESS_IMPACT_VERSION = '2.1.0';
type Cluster = ReportContextPublication['clusters'][number];
interface ManagementTheme {
id: string;
label: string;
question: string;
impact: string;
action: string;
limitation: string;
clusterIds: readonly string[];
}
/**
* Four disjoint themes (user decision 2026-10-04): every problem category belongs to exactly one theme, the
* consequence an organisation loses first. Financial and compliance consequences can follow from every theme;
* they are stated in the report prose, never counted as a theme of their own.
*/
export const managementThemes: readonly ManagementTheme[] = [
{ id: 'control', label: 'Kontrolle & Vertrauen', question: 'Kann die Kontrolle über Systeme und Identitäten verloren gehen?',
impact: 'Die Übernahme oder der Missbrauch von Identitäten könnte unberechtigte Kontrolle über Systeme, Berechtigungen und Vertrauensbeziehungen ermöglichen. Betroffen wäre dann nicht ein einzelnes System, sondern alles, was dieselben Identitätsdienste nutzt.',
action: 'Privilegierte Berechtigungen, Delegation und Authentifizierung anhand der Originalempfehlungen prüfen und administrative Sicherheitsgrenzen fachlich überprüfen.',
limitation: 'Gemeinsame Themen belegen weder eine durchgängige Angriffskette noch eine erfolgreiche Übernahme.',
clusterIds: ['cloud-privileged-access-exposure', 'ad-privileged-account-exposure', 'kerberos-delegation-abuse', 'adcs-escalation',
'ad-persistence-indicators', 'credential-relay-exposure', 'cloud-sign-in-protection-gaps', 'kerberoasting-exposure',
'host-credential-privilege-exposure', 'password-guessing-exposure', 'physical-local-access-exposure'] },
{ id: 'information', label: 'Informationen & Daten', question: 'Sind Unternehmensinformationen gefährdet?',
impact: 'Unberechtigte Zugriffe könnten die Vertraulichkeit von Unternehmensinformationen beeinträchtigen. Die Problemfelder dieses Themas betreffen Wege, auf denen Daten den vorgesehenen Kreis verlassen oder Außenstehende Einblick erhalten.',
action: 'Zugriffsrechte, Freigaben und externe Zugänge anhand der Originalempfehlungen prüfen. Relevante Datenbestände und deren Schutzbedarf fachlich zuordnen.',
limitation: 'Ein tatsächlicher Datenzugriff oder Datenverlust sowie die Sensibilität betroffener Informationen sind damit nicht nachgewiesen.',
clusterIds: ['cloud-external-access-exposure', 'data-sharing-exposure', 'cloud-app-consent-exposure', 'email-spoofing-phishing-exposure', 'ad-reconnaissance-exposure'] },
{ id: 'continuity', label: 'Betriebsfähigkeit', question: 'Kann das Unternehmen weiterarbeiten?',
impact: 'Die Ausbreitung eines Angriffs oder die Ausführung schädlichen Codes könnte die Nutzung abhängiger Systeme und damit Geschäftsabläufe beeinträchtigen oder unterbrechen. Je leichter sich ein Angriff von System zu System bewegt, desto mehr Systeme könnten gleichzeitig betroffen sein.',
action: 'Begrenzung der Schadensausbreitung und Schutz vor schädlichem Code anhand der Originalempfehlungen prüfen. Abhängige geschäftskritische Dienste mit den Fachverantwortlichen bestimmen.',
limitation: 'Betroffene Geschäftsprozesse und mögliche Ausfallzeiten sind aus diesen Befunden nicht bestimmt.',
clusterIds: ['lateral-movement-exposure', 'malicious-code-execution-exposure'] },
{ id: 'resilience', label: 'Widerstandsfähigkeit & Wiederanlauf', question: 'Kann das Unternehmen Auswirkungen begrenzen und sich erholen?',
impact: 'Lücken bei Erkennung, Aktualität oder Wiederherstellung könnten die Reaktion auf einen Vorfall verzögern und die Rückkehr zu einem verlässlichen Betrieb erschweren. Dieses Thema bestimmt weniger, ob ein Vorfall eintritt, als wie früh er bemerkt und wie vollständig er behoben werden kann.',
action: 'Die Originalempfehlungen zu Erkennung, Patch-Stand und Wiederherstellung prüfen. Vorhandene Wiederanlaufverfahren durch geeignete Tests bestätigen.',
limitation: 'Die tatsächliche Wiederherstellbarkeit und Wiederanlaufzeiten sind durch diese Befunde nicht bestätigt.',
clusterIds: ['detection-blind-spots', 'ad-recovery-gaps', 'patch-configuration-drift'] },
];
/** Consequences that can follow from every theme; stated once, never counted. */
export const MANAGEMENT_DOWNSTREAM_CONSEQUENCES = 'Aus jedem Thema können finanzielle Folgen (Aufwand für Untersuchung, Eindämmung und Wiederherstellung, Folgen einer Betriebsunterbrechung) und Compliance-Folgen (Schutz- und Nachweisanforderungen) entstehen. Sie werden nicht als eigenes Thema gezählt.';
const themeOfCluster = new Map(managementThemes.flatMap(theme => theme.clusterIds.map(clusterId => [clusterId, theme.id] as const)));
export function managementBusinessImpacts(context: ReportContextPublication): Array<ManagementTheme & { clusters: Cluster[]; findingCount: number }> {
return managementThemes.map(theme => {
const clusters = context.clusters.filter(item => item.hasEvidence && item.scanner + item.gpo > 0 && theme.clusterIds.includes(item.id));
return { ...theme, clusters, findingCount: new Set(clusters.flatMap(item => item.findingKeys ?? item.resultIds)).size };
});
}
/** A partition, never a sum of possible effects: one finding, one theme. Findings without a category stay unassigned. */
export function managementFindingThemeAssignments(context: ReportContextPublication): Map<string, string> {
const assignments = new Map<string, Set<string>>();
for (const cluster of context.clusters) {
if (!cluster.hasEvidence || cluster.scanner + cluster.gpo === 0) continue;
for (const key of cluster.findingKeys ?? cluster.resultIds) {
const themes = assignments.get(key) ?? new Set<string>();
const theme = themeOfCluster.get(cluster.id);
if (theme) themes.add(theme);
assignments.set(key, themes);
}
}
const primary = new Map<string, string>();
for (const [key, themes] of assignments) {
if (themes.size !== 1) continue;
const theme = [...themes][0];
primary.set(key, theme);
}
return primary;
}
export function managementFindingDistribution(context: ReportContextPublication, total: number, workspaceKeys?: readonly string[]) {
const allowed = workspaceKeys ? new Set(workspaceKeys) : undefined;
const counts = new Map<string, number>();
for (const [key, theme] of managementFindingThemeAssignments(context)) {
if (!allowed || allowed.has(key)) counts.set(theme, (counts.get(theme) ?? 0) + 1);
}
const assigned = [...counts.values()].reduce((sum, count) => sum + count, 0);
if (assigned > total) throw new Error('Theme distribution exceeds the authoritative finding population.');
return { themes: managementThemes.map(theme => ({ id: theme.id, label: theme.label, count: counts.get(theme.id) ?? 0 })),
unassigned: total - assigned, total };
}

View File

@ -2,9 +2,10 @@ import type { ReportDomainSummary, ReportProjection } from './report-projection'
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { escapeHtml } from '../shared/html';
import { statementText } from './report-statement-text';
import { buildContextManagementHtml } from './context-management-report';
/** Offline management report: a factual view of the shared projection. */
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.1.0';
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.0';
const DASH = '—';
const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH;
@ -49,6 +50,8 @@ function keyStatements(projection: ReportProjection): string {
}
export function buildManagementOverviewHtml(projection: ReportProjection): string {
// Retain the historical renderer for archived projections; new management runs require Context.
if (projection.context) return buildContextManagementHtml(projection);
const domain = projection.environment.find(fact => fact.key === 'Domain')?.value;
const dc = projection.environment.find(fact => fact.key === 'Domain Controller')?.value;
const gpo = projection.gpo;

View File

@ -0,0 +1,157 @@
/**
* Fixed German report text per published Context category (user request 2026-10-04: considered text instead of
* repeated generic phrases). Report-only wording over published categories: no new assignment, no impact rating,
* no scanner names, no owners, deadlines or stages. Each statement describes what the category covers and what it
* favours; it never claims that an attack is possible or took place.
*/
export const MANAGEMENT_REPORT_TEXTS_VERSION = '1.0.0';
export interface ManagementCategoryText {
/** German reading name; the English category name stays the reference to the heatmap. */
name: string;
/** Sachverhalt: what the deviations in this category concern. */
situation: string;
/** Einordnung: what the condition favours, phrased as possibility. */
significance: string;
/** Ansatzpunkt: the technical lever the original recommendations address. */
lever: string;
}
export const managementCategoryTexts: Readonly<Record<string, ManagementCategoryText>> = {
'cloud-privileged-access-exposure': {
name: 'Privilegierte Cloud-Konten',
situation: 'Die Abweichungen betreffen administrative Rollen im Cloud-Verzeichnis: dauerhaft statt bedarfsweise zugewiesene Rollen, fehlende oder nicht phishingresistente Mehrfaktor-Anmeldung, synchronisierte statt reiner Cloud-Konten oder inaktive privilegierte Konten.',
significance: 'Wer ein solches Konto übernimmt, erhält die Kontrolle über den gesamten Tenant, seine Identitäten und Daten. Dauerhafte Zuweisungen verlängern die Zeit, in der ein abgegriffenes Konto nutzbar bleibt.',
lever: 'Privilegierte Rollen nur zeitlich begrenzt aktivieren, für sie phishingresistente Mehrfaktor-Anmeldung verlangen und ihre Zahl auf das Notwendige begrenzen.'
},
'ad-privileged-account-exposure': {
name: 'Privilegierte AD-Konten',
situation: 'Die Abweichungen betreffen den Schutz administrativer Konten im Active Directory, etwa fehlende Mitgliedschaft in Protected Users, die Nutzung des eingebauten Administrators oder Mitgliedschaften in hochprivilegierten Gruppen.',
significance: 'Anmeldedaten privilegierter Konten, die abgegriffen und wiederverwendet werden, ermöglichen die Kontrolle über die gesamte Domäne.',
lever: 'Administrative Konten in Protected Users aufnehmen, hochprivilegierte Gruppen leeren oder begrenzen und das eingebaute Administratorkonto nicht im Alltag verwenden.'
},
'kerberos-delegation-abuse': {
name: 'Kerberos-Delegation',
situation: 'Die Abweichungen betreffen Delegationseinstellungen: uneingeschränkte Delegation, Delegation auf Domain-Controller-Dienste, änderbare ressourcenbasierte Delegation oder administrative Konten, die delegiert werden dürfen.',
significance: 'Fehlerhafte Delegation erlaubt einem Dienst oder Konto, sich gegenüber anderen Systemen als beliebige Nutzer auszugeben, einschließlich Administratoren.',
lever: 'Uneingeschränkte Delegation entfernen, Delegation auf die benötigten Dienste beschränken und administrative Konten als nicht delegierbar kennzeichnen.'
},
'adcs-escalation': {
name: 'Zertifikatsdienste (AD CS)',
situation: 'Die Abweichungen betreffen Zertifikatvorlagen und Zertifizierungsstellen: Vorlagen, in denen der Antragsteller den Namen selbst bestimmt, unsichere Vorlageneinstellungen, zu weit gefasste Berechtigungen oder Web Enrollment.',
significance: 'Ein Zertifikat im Namen eines anderen Kontos wirkt wie dessen Anmeldedaten und bleibt auch nach einer Kennwortänderung gültig.',
lever: 'Vorlagen mit frei wählbarem Antragstellernamen einschränken, Berechtigungen auf Vorlagen und Container prüfen und Web Enrollment absichern oder abschalten.'
},
'credential-relay-exposure': {
name: 'Weiterleitung von Anmeldungen',
situation: 'Die Abweichungen betreffen Netzwerkauthentifizierung und Namensauflösung: fehlende Signierung bei SMB und LDAP, zugelassene ältere NTLM-Varianten sowie aktive Broadcast-Namensauflösung wie LLMNR, NetBIOS oder WPAD.',
significance: 'Unter diesen Bedingungen lassen sich Anmeldungen im Netz abfangen und an andere Dienste weiterreichen, ohne das Kennwort zu kennen.',
lever: 'Signierung für SMB und LDAP erzwingen, LDAP Channel Binding aktivieren, ältere NTLM-Varianten ablehnen und Broadcast-Namensauflösung abschalten.'
},
'cloud-sign-in-protection-gaps': {
name: 'Schutz der Cloud-Anmeldung',
situation: 'Die Abweichungen betreffen Anmelderichtlinien im Cloud-Verzeichnis: zugelassene Legacy-Authentifizierung, nicht blockierte riskante Anmeldungen, schwache Mehrfaktor-Methoden und Lücken im Geltungsbereich von Zugriffsrichtlinien.',
significance: 'Erratene, gestohlene oder abgefangene Zugangsdaten führen unter diesen Bedingungen eher zu einer erfolgreichen Anmeldung.',
lever: 'Legacy-Authentifizierung blockieren, riskante Anmeldungen durch Richtlinien abdecken und phishingresistente Mehrfaktor-Methoden bevorzugen.'
},
'kerberoasting-exposure': {
name: 'Dienstkonten und Kerberos-Verschlüsselung',
situation: 'Die Abweichungen betreffen Konten mit Dienstprinzipalnamen und die zugelassene Kerberos-Verschlüsselung: privilegierte Dienstkonten, fehlende AES-Unterstützung, RC4 oder DES sowie ungenutzte verwaltete Dienstkonten.',
significance: 'Service-Tickets solcher Konten lassen sich von jedem Domänennutzer anfordern und offline auf das Kennwort prüfen.',
lever: 'Dienstkonten mit langen, automatisch verwalteten Kennwörtern betreiben, RC4 und DES abschalten und privilegierten Konten keine Dienstprinzipalnamen zuweisen.'
},
'host-credential-privilege-exposure': {
name: 'Anmeldedaten und Rechte auf Systemen',
situation: 'Die Abweichungen betreffen die Härtung einzelner Systeme: gespeicherte Anmeldedaten, abgeschwächte Benutzerkontensteuerung, weit vergebene Benutzerrechte, Druckertreiber-Installation und die Weitergabe von Anmeldedaten bei Remote-Verbindungen.',
significance: 'Diese Bedingungen begünstigen, auf einem erreichten System Anmeldedaten auszulesen oder lokale Rechte zu erhöhen.',
lever: 'Speicherung von Anmeldedaten unterbinden, Benutzerrechte auf die vorgesehenen Gruppen begrenzen und Benutzerkontensteuerung sowie Druckerrichtlinien gemäß Empfehlung setzen.'
},
'password-guessing-exposure': {
name: 'Kennwortrichtlinie und Kontosperre',
situation: 'Die Abweichungen betreffen Kennwortlänge und -komplexität, die Kontosperre nach Fehlversuchen, nie ablaufende Kennwörter und die Speicherung von Kennwörtern.',
significance: 'Kurze, unveränderte oder nicht durch eine Sperre geschützte Kennwörter lassen sich leichter erraten oder knacken.',
lever: 'Mindestlänge und Kontosperre gemäß Empfehlung setzen, für privilegierte und Dienstkonten strengere Richtlinien anwenden und dauerhaft gültige Kennwörter abbauen.'
},
'ad-persistence-indicators': {
name: 'Dauerhafte Rechte im Verzeichnis',
situation: 'Die Abweichungen betreffen geänderte Berechtigungen des AdminSDHolder-Objekts oder Konten, die eine frühere administrative Kennzeichnung behalten haben.',
significance: 'Solche Veränderungen können dauerhafte, schwer erkennbare Rechte an geschützten Konten sichern.',
lever: 'Berechtigungen des AdminSDHolder-Objekts mit dem Standard abgleichen und verwaiste administrative Kennzeichnungen bereinigen.'
},
'physical-local-access-exposure': {
name: 'Lokaler und physischer Zugang',
situation: 'Die Abweichungen betreffen Anmelde- und Sperrbildschirm, Geräteanschlüsse, lokale Anmelderechte und das Verhalten nach Ablauf der Anmeldezeiten.',
significance: 'Wer physischen oder lokalen Zugang zu einem System hat, kann unter diesen Bedingungen leichter Daten entnehmen oder Sitzungen nutzen.',
lever: 'Sperrbildschirm, lokale Anmelderechte und Geräterichtlinien gemäß Empfehlung setzen.'
},
'cloud-external-access-exposure': {
name: 'Externer Zugang zum Tenant',
situation: 'Die Abweichungen betreffen Gasteinladungen, Selbstregistrierung, Rechte von Gästen im Verzeichnis, externe Freigaben und den Zutritt zu Besprechungen.',
significance: 'Externe Identitäten erhalten unter diesen Bedingungen leichter und mit weniger Kontrolle Zugang zu Tenant-Daten.',
lever: 'Einladungen auf berechtigte Rollen und freigegebene Domänen begrenzen und externe Freigaben auf das vorgesehene Maß einschränken.'
},
'data-sharing-exposure': {
name: 'Datenübermittlung an externe Dienste',
situation: 'Die Abweichungen betreffen Telemetrie und Diagnose, Cloud-Inhalte und Synchronisierung sowie automatische Internetkommunikation von Systemen.',
significance: 'Systeme übermitteln unter diesen Bedingungen Informationen an externe Dienste, ohne dass dies zentral gesteuert ist.',
lever: 'Telemetrie-, Synchronisierungs- und Kommunikationseinstellungen auf das organisatorisch vorgesehene Maß setzen.'
},
'cloud-app-consent-exposure': {
name: 'Anwendungszustimmungen im Tenant',
situation: 'Die Abweichungen betreffen die Zustimmung zu Anwendungen durch Nutzer, fehlende Admin-Prüfung von Zustimmungen sowie ungenutzte oder auslaufende Anmeldedaten von Anwendungen.',
significance: 'Eine zugestimmte oder vergessene Anwendung kann dauerhaft und unabhängig von Nutzerkennwörtern auf Tenant-Daten zugreifen.',
lever: 'Zustimmungen auf Administratoren oder einen Prüfprozess beschränken und ungenutzte Anwendungen und Anmeldedaten entfernen.'
},
'email-spoofing-phishing-exposure': {
name: 'E-Mail-Fälschung und Phishing',
situation: 'Die Abweichungen betreffen die Absenderauthentifizierung (SPF, DKIM, DMARC), Schutzfilter für Links, Anhänge und Schadsoftware sowie automatische Weiterleitungen an externe Empfänger.',
significance: 'Gefälschte oder schädliche E-Mails erreichen unter diesen Bedingungen eher ihre Empfänger, und Daten können per Weiterleitung abfließen.',
lever: 'Absenderauthentifizierung durchsetzen, Schutzfilter gemäß Empfehlung setzen und externe Weiterleitungen unterbinden.'
},
'ad-reconnaissance-exposure': {
name: 'Erkundung des Verzeichnisses',
situation: 'Die Abweichungen betreffen anonyme oder sehr breite Lesezugriffe auf Active Directory, abfragbare Netzwerksitzungen, anonym erreichbare Named Pipes und das Gastkonto.',
significance: 'Diese Bedingungen erleichtern es, Konten, Gruppen und Systeme ohne oder mit geringen Rechten zu erkunden.',
lever: 'Anonymen Zugriff abschalten, die Gruppe Pre-Windows 2000 Compatible Access bereinigen und Sitzungsabfragen einschränken.'
},
'lateral-movement-exposure': {
name: 'Ausbreitung im Netzwerk',
situation: 'Die Abweichungen betreffen Fernzugriffsdienste und Netzwerkschutz: Remote Desktop ohne vorgeschaltete Authentifizierung, unverschlüsselte Fernverwaltung, SMBv1, Firewall-Profile, weit gefasste Netzwerk-Anmelderechte und lokale Administratorkennwörter.',
significance: 'Mit einmal erlangten gültigen Anmeldedaten lässt sich ein Angriff unter diesen Bedingungen leichter von System zu System ausweiten.',
lever: 'Fernzugriffsdienste absichern oder begrenzen, Firewall-Profile aktivieren, SMBv1 abschalten und lokale Administratorkennwörter je System verwalten.'
},
'malicious-code-execution-exposure': {
name: 'Ausführung schädlicher Inhalte',
situation: 'Die Abweichungen betreffen Malware- und Exploit-Schutz, Regeln zur Reduktion der Angriffsfläche, die Behandlung heruntergeladener Dateien, AutoRun sowie Skript- und Anwendungssteuerung.',
significance: 'Schädliche Dateien, Skripte oder Wechselmedien werden unter diesen Bedingungen eher ausgeführt.',
lever: 'Schutzfunktionen vollständig aktivieren, Regeln zur Reduktion der Angriffsfläche im Blockiermodus betreiben und die Ausführung von Skripten und Anwendungen steuern.'
},
'detection-blind-spots': {
name: 'Protokollierung und Erkennung',
situation: 'Die Abweichungen betreffen Überwachungsrichtlinien, Prozess- und PowerShell-Protokollierung, die Größe von Ereignisprotokollen, Firewall-Protokolle und die Weiterleitung von Sicherheitsprotokollen.',
significance: 'Aktivitäten werden unter diesen Bedingungen nicht oder nicht lange genug aufgezeichnet; ein Vorfall wird dann später oder gar nicht erkannt und lässt sich schwerer nachvollziehen.',
lever: 'Überwachungsrichtlinien und Protokollierung gemäß Empfehlung aktivieren, Protokolle ausreichend dimensionieren und zentral auswerten.'
},
'ad-recovery-gaps': {
name: 'Wiederherstellung des Verzeichnisses',
situation: 'Die Abweichungen betreffen Alter der Verzeichnissicherung, den AD-Papierkorb, den Löschschutz von Organisationseinheiten und die Redundanz von Domain Controllern.',
significance: 'Nach einer Löschung oder Zerstörung wird die Wiederherstellung des Verzeichnisses unter diesen Bedingungen länger oder unvollständig.',
lever: 'Regelmäßige, geprüfte Sicherungen sicherstellen, den AD-Papierkorb aktivieren und Organisationseinheiten gegen versehentliches Löschen schützen.'
},
'patch-configuration-drift': {
name: 'Aktualisierung und Konfigurationsstand',
situation: 'Die Abweichungen betreffen Windows Update, die Verarbeitung von Gruppenrichtlinien und die Zeitsynchronisierung.',
significance: 'Bekannte Schwachstellen und abweichende Einstellungen bleiben unter diesen Bedingungen länger bestehen.',
lever: 'Update-Richtlinien, Gruppenrichtlinienverarbeitung und Zeitsynchronisierung gemäß Empfehlung setzen.'
}
};
/** Consolidated reading notes, stated once in the report instead of in every row. */
export const MANAGEMENT_READING_NOTES: readonly string[] = [
'Ein Problemfeld fasst Abweichungen zusammen, die dieselbe Art von Angriffsweg begünstigen. Das ist kein Nachweis, dass ein Angriff möglich ist oder stattgefunden hat.',
'Jede Abweichung zählt in genau einem Problemfeld und damit in genau einem Thema. Zahlen verschiedener Problemfelder und Themen lassen sich deshalb addieren.',
'Die Grundschwere ist die kuratierte Bedeutung eines Problemfelds. Sie ist keine Severity eines einzelnen Befunds, keine Eintrittswahrscheinlichkeit und keine Bewertung des tatsächlichen Geschäftsrisikos.',
'Der mögliche Business Impact beschreibt Folgen, die eintreten könnten, wenn sich die begünstigten Bedingungen in einem Vorfall auswirken. Ausmaß, Wahrscheinlichkeit und Kosten werden nicht bewertet.',
'Gruppenrichtlinien-Befunde beschreiben die erhobene Konfiguration der Richtlinien-Sicherungen, nicht den wirksamen Zustand jedes Geräts.',
'Nicht ausgeführte Prüfungen und fehlende Daten werden nicht als bestanden gewertet.'
];

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,31 @@
/** Published result contract only: Report Engine has no dependency on Context Engine internals. */
export interface ReportContextPublication {
version: 1;
projectId: string;
assessmentIds: string[];
scanRunIds: string[];
repositoryRevision: number;
generatedAt: string;
engineVersion: string;
catalogVersion: string;
severityCatalogVersion: string;
vocabularyVersion: string;
findingCount: number;
clusters: Array<{
id: string; label: string; description: string; severity: 'critical' | 'high' | 'medium' | 'undetermined';
hasEvidence: boolean; scanner: number; gpo: number;
confidence: { catalogued: number; recognized: number; estimated: number };
tools: string[]; resultIds: string[];
/** Unique Work counting units, distinct from multiple technical evidence rows per GPO setting. */
findingKeys?: string[];
sources: Array<{ publisher: string; title: string; url: string }>;
}>;
sharedSettingLevers: Array<{ settings: number; conditions: number; clusters: number; backups: number }>;
}
export class ReportContextRequiredError extends Error {
constructor() {
super('Für den Management Report sind Context-Ergebnisse desselben Assessment-Stands erforderlich. Bitte Context Engine aktivieren und erneut versuchen.');
this.name = 'ReportContextRequiredError';
}
}

File diff suppressed because one or more lines are too long

View File

@ -52,6 +52,19 @@ export async function assembleReportProjectionInput(
repository.listPolicyEvaluations(scope.projectId)
]);
const dataset = workspace.dataset;
const summaryWorkspace = await loadCanonicalProjectWorkspace({
listProjectAssessments: projectId => repository.listAssessments(projectId),
loadAssessmentModel: async assessmentId => {
const model = await repository.loadAssessmentModel(assessmentId);
if (!model) throw new Error(`Assessment ${assessmentId} not found.`);
return model;
},
queryUnifiedResults: query => repository.queryUnifiedResults(query),
listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }),
listCurrentPolicyEvaluationsForMetrics: repository.listCurrentPolicyEvaluationsForMetrics
? async projectId => ({ records: await repository.listCurrentPolicyEvaluationsForMetrics!(projectId) }) : undefined
}, scope.projectId, scope.scanRunIds, assessmentIds,
{ includeHistoricalScores: false, includeBaselineGuidance: false, summaryOnly: true });
const selectedObservationIds = new Set(results.filter(result => result.sourceKind === 'policy-observation')
.map(result => result.sourceResultId));
const scopedAnalyses = analyses.map(record => ({
@ -69,7 +82,7 @@ export async function assembleReportProjectionInput(
const reviews = hydrateWorkspaceReviews(dataset, storedReviews, {}).reviews;
const preliminary = clientReportInput({
project, assessmentIds: workspace.assessmentIds, generatedAt: scope.generatedAt,
results, dataset, environment: snapshot, reviews
results, dataset, workspaceDataset: summaryWorkspace.dataset ?? undefined, environment: snapshot, reviews
});
const ratingByTool = new Map(securitySourceScores(dataset.securityScoring?.scans ?? []).map(item => [item.toolId, item]));
const resultsByTool = new Map<string, typeof results>();

View File

@ -1,4 +1,5 @@
import type { CanonicalOutcome, CanonicalSeverity } from '../domain/canonical-model';
import type { ReportContextPublication } from './report-context';
import type { RemediationEvidenceState } from '../domain/timeslot-comparison';
import type { ToolPriorityBasisDefinition } from '../domain/tool-profile-definition';
import type { UnifiedResultProjection } from '../domain/unified-result';
@ -17,7 +18,7 @@ import { buildReportStatements, type ReportStatement } from './report-narrative'
* 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.2.0';
export const REPORT_PROJECTION_VERSION = '1.3.0';
/* -------------------------------------------------------------------------- */
/* Priority basis */
@ -191,6 +192,8 @@ export interface ReportProjectionInput {
domainOf: (result: UnifiedResultProjection) => string;
priorityBases?: Readonly<Record<string, ReportPriorityBasis>>;
scoreSources?: ReportScoreSource[];
workspaceCounts?: { deviations: number; assessed: number };
workspaceDeviationKeys?: string[];
overallScore?: number;
environment?: ReportFact[];
policyEntries?: ReportPolicyEntry[];
@ -340,6 +343,10 @@ export interface ReportDelta {
}
export interface ReportProjection {
/** New runs never create a legacy management file without Context; historical snapshots retain their renderer. */
managementContextRequired?: boolean;
/** Frozen published Context result, never computed by this engine. */
context?: ReportContextPublication;
version: typeof REPORT_PROJECTION_VERSION;
generatedAt: string;
project: { id: string; name: string };
@ -347,6 +354,8 @@ export interface ReportProjection {
environment: ReportFact[];
overallScore?: number;
scoreSources: ReportScoreSource[];
workspaceCounts?: { deviations: number; assessed: number };
workspaceDeviationKeys?: string[];
domains: ReportDomainSummary[];
priorities: ReportDomainPriority[];
gpo?: ReportGpoBlock;
@ -690,6 +699,8 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje
environment: input.environment ?? [],
overallScore: input.overallScore,
scoreSources: input.scoreSources ?? [],
workspaceCounts: input.workspaceCounts ? { ...input.workspaceCounts } : undefined,
workspaceDeviationKeys: input.workspaceDeviationKeys ? [...input.workspaceDeviationKeys] : undefined,
domains: domainSummaries,
priorities,
gpo,

View File

@ -10,6 +10,7 @@ export type ReportReadMethods = Pick<AssessorRepository,
| 'loadAssessmentModel'
| 'queryUnifiedResults'
| 'listPolicyEvaluations'
| 'listCurrentPolicyEvaluationsForMetrics'
| 'listMicrosoftSecurityBaselineAnalyses'
| 'getEnvironmentSnapshot'
| 'listReviews'
@ -42,5 +43,8 @@ export function reportReadPortFrom(
const method = repository[name] as (...args: unknown[]) => unknown;
(port as unknown as Record<string, unknown>)[name] = (...args: unknown[]) => method.apply(repository, args);
}
if (repository.listCurrentPolicyEvaluationsForMetrics) {
port.listCurrentPolicyEvaluationsForMetrics = projectId => repository.listCurrentPolicyEvaluationsForMetrics!(projectId);
}
return Object.freeze(port);
}

View File

@ -5,9 +5,10 @@ import { REPORT_NARRATIVE_VERSION } from './report-narrative';
import { assembleReportProjectionInput, type ReportAssemblyScope } from './report-input-assembler';
import { buildReportProjection, REPORT_PROJECTION_VERSION, type ReportProjection, type ReportProjectionInput } from './report-projection';
import type { ReportReadPort } from './report-read-port';
import { ReportContextRequiredError, type ReportContextPublication } from './report-context';
import { buildTechnicalReportHtml, TECHNICAL_REPORT_RENDERER_VERSION } from './technical-report';
export const REPORT_ENGINE_VERSION = '0.2.0';
export const REPORT_ENGINE_VERSION = '0.3.0';
const DEFAULT_ATTEMPTS = 3;
export class ReportStateChangedError extends Error {
@ -57,7 +58,7 @@ export async function assembleConsistentReportInput(
/** The rendered files of one report run. Rendering depends on the projection only. */
export function renderReportFiles(projection: ReportProjection): { management: string; technical: string } {
return {
management: buildManagementOverviewHtml(projection),
management: projection.managementContextRequired && !projection.context ? '' : buildManagementOverviewHtml(projection),
technical: buildTechnicalReportHtml(projection)
};
}
@ -70,8 +71,28 @@ export interface ReportRun {
files: { management: string; technical: string };
}
export async function createReportRun(port: ReportReadPort, scope: ReportAssemblyScope): Promise<ReportRun> {
const { input, revision } = await assembleConsistentReportInput(port, scope);
const projection = buildReportProjection(input);
return { projection, revision, scope, versions: reportEngineVersions(), files: renderReportFiles(projection) };
export interface ReportRunOptions {
requireContext?: boolean;
readContext?: (scope: ReportAssemblyScope & { assessmentIds: string[] }) => Promise<ReportContextPublication>;
}
export async function createReportRun(port: ReportReadPort, scope: ReportAssemblyScope, options: ReportRunOptions = {}): Promise<ReportRun> {
const requireContext = options.requireContext !== false;
if (requireContext && !options.readContext) throw new ReportContextRequiredError();
for (let attempt = 0; attempt < DEFAULT_ATTEMPTS; attempt++) {
const { input, revision } = await assembleConsistentReportInput(port, scope);
const assessmentIds = scope.assessmentIds ?? (scope.assessmentId ? [scope.assessmentId]
: (await port.listAssessments(scope.projectId)).map(item => item.id));
const context = options.readContext ? await options.readContext({ ...scope, assessmentIds }) : undefined;
if (context && (context.projectId !== scope.projectId
|| JSON.stringify([...context.assessmentIds].sort()) !== JSON.stringify([...assessmentIds].sort())
|| JSON.stringify([...context.scanRunIds].sort()) !== JSON.stringify([...(scope.scanRunIds ?? [])].sort()))) throw new ReportContextRequiredError();
if (await port.currentRevision() !== revision || (context && context.repositoryRevision !== revision)) continue;
const projection = buildReportProjection(input);
projection.managementContextRequired = true;
if (context) projection.context = structuredClone(context);
if (requireContext && !projection.context) throw new ReportContextRequiredError();
return { projection, revision, scope, versions: reportEngineVersions(), files: renderReportFiles(projection) };
}
throw new ReportStateChangedError();
}

View File

@ -135,6 +135,7 @@ ${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDom
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section>
<section class="block"><h2>Belege der Kernaussagen</h2><p class="limit">Jede Kernaussage des Management Reports mit den Result-IDs hinter ihren Zahlen. Die IDs stehen im Anhang im vollständigen Result-Index.</p>
${statementEvidence || '<p class="limit">Keine Kernaussagen in diesem Snapshot.</p>'}</section>
${projection.context ? `<section class="block"><h2>Context-Belege des Management Reports</h2><p>Katalog ${escapeHtml(projection.context.catalogVersion)} · Kategorie-Grundschwere ${escapeHtml(projection.context.severityCatalogVersion)}. Mehrfachzuordnungen werden nicht addiert; erkannte und geschätzte Zuordnungen bleiben abgeleiteter Kontext.</p>${table(['Problemkategorie', 'Base Severity', 'Scanner-Findings', 'GPO-Abweichungen', 'Katalogisiert / erkannt / geschätzt', 'Result-IDs'], projection.context.clusters.map(item => [plain(item.label), plain(item.severity), number(item.scanner), number(item.gpo), plain(`${item.confidence.catalogued} / ${item.confidence.recognized} / ${item.confidence.estimated}`), plain(item.resultIds.join(', '))]))}</section>` : ''}
<section class="block"><h2>Findings im Detail</h2>${grouped.length ? grouped.map(group => `<section><h3>${escapeHtml(group.label)}</h3>${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}</section>`).join('') : '<p>Keine Deviations in diesem Snapshot.</p>'}</section>
<section class="block"><h2>GPO Policy Register</h2><p class="limit">Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.</p>
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [

View File

@ -220,8 +220,9 @@ mark, .search-highlight {
.reader-summary > .policy-problem[data-outcome="pass"] { border-left-color: var(--outcome-pass); }
.policy-problem h4, .policy-next-step h4 { margin: 0; }
/* Context layer (Phase 13E): a neutral second reading. Tile surface and separators only; no status colour, state is text plus glyph. */
/* Explicit user exception: the Engines control remains marked blue in every state. */
.btn.engines-button, .btn.engines-button:hover, .btn.engines-button:active { background: var(--engines-button-bg); color: var(--engines-button-text); border-color: var(--engines-button-bg); }
/* Explicit user exceptions: Engines and the two Client Info report actions stay blue, including hover. */
.btn.engines-button, .btn.engines-button:hover, .btn.engines-button:active,
.btn.client-information-action, .btn.client-information-action:hover, .btn.client-information-action:active { background: var(--engines-button-bg); color: var(--engines-button-text); border-color: var(--engines-button-bg); }
.dialog-window.engines-dialog { width: min(var(--engines-dialog-width), 100%); }
.engines-list { display: grid; gap: var(--space-3); }
.engines-option { display: flex; align-items: center; gap: var(--space-3); font-size: var(--type-body-size); }
@ -940,11 +941,29 @@ table tbody tr[data-row-marked="true"] > :is(th, td) {
/* Meaning of a standard Windows code, shown right after the unchanged code. */
.windows-code-meaning { color: var(--muted); font-weight: var(--fw-regular); }
/* Shared operation visibility, using the existing neutral panel and controls. */
.activity-center { position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); width: min(var(--activity-panel-width), calc(100vw - var(--panel-inset) * 2)); max-height: 40vh; overflow: auto; padding: var(--space-3); border: var(--bw-hairline) solid var(--border-default); background: var(--surface-panel); color: var(--text-primary); }
.activity-center-header { display: flex; justify-content: space-between; gap: var(--space-2); }
.activity-center-status { display: block; padding-block: var(--space-2); font-size: var(--fs-md); }
/* Shared operation visibility: stable header, scrollable disclosure and real task status. */
.activity-minimized { display: flex; align-items: center; gap: var(--space-2); position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); max-width: calc(100vw - var(--panel-inset) * 2); }
.activity-center { position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); width: min(var(--activity-panel-width), calc(100vw - var(--panel-inset) * 2)); max-height: 40vh; display: flex; flex-direction: column; padding: var(--panel-inset); border: var(--bw-hairline) solid var(--border-default); background: var(--surface-panel); color: var(--text-primary); }
.activity-center-header { display: flex; align-items: start; justify-content: space-between; gap: var(--space-2); flex: none; }
.activity-header-actions, .activity-compact-toggle { display: inline-flex; align-items: center; gap: var(--space-2); }
.activity-reload svg { display: block; width: var(--space-4); height: var(--space-4); }
.activity-header-actions { flex: none; }
.activity-ready-dot { width: var(--space-2); height: var(--space-2); flex: none; border-radius: 50%; background: var(--state-online); }
.activity-center-header .activity-toggle { height: auto; min-height: var(--btn-h); white-space: normal; text-align: left; display: flex; align-items: center; gap: var(--space-2); }
.activity-center-body { overflow: auto; min-height: 0; padding-top: var(--space-3); }
.activity-center h4 { margin: 0; padding-block: var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.activity-center ul { list-style: none; margin: 0; padding: 0; }
.activity-center li { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); padding-block: var(--space-2); border-top: var(--bw-hairline) solid var(--border-default); font-size: var(--fs-md); }
.activity-center li div { display: grid; gap: var(--space-1); }
.activity-center li > span { white-space: nowrap; }
.activity-center li { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); padding-block: var(--space-2); border-top: var(--bw-hairline) solid var(--border-default); font-size: var(--type-table-body-size); }
.activity-operation { display: grid; gap: var(--space-1); min-width: 0; overflow-wrap: anywhere; }
.activity-operation strong { font-weight: var(--fw-semibold); }
.activity-operation > span, .activity-group summary > span { color: var(--text-secondary); }
.activity-state { display: grid; justify-items: end; gap: var(--space-1); flex: none; font-variant-numeric: tabular-nums; }
.activity-center li[data-state="failed"] { border-left: var(--marker-width) solid var(--state-blocked); padding-left: var(--space-2); }
.activity-history > summary, .activity-group summary { cursor: pointer; padding-block: var(--space-2); font-size: var(--type-table-body-size); }
.activity-group summary > span { display: block; margin-top: var(--space-1); }
.activity-center .activity-group { display: block; }
.activity-clear { margin-block: var(--space-2); }
.activity-history summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); }
.activity-center .sr-only { position: absolute; width: var(--bw-hairline); height: var(--bw-hairline); overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.activity-ready { margin: 0; padding-block: var(--space-2); font-size: var(--type-body-size); }

View File

@ -46,6 +46,7 @@
.datahub-tenant-processing-dot[data-status="review"] { background: var(--warning); }
.datahub-tenant-processing-dot[data-status="checking"] { background: var(--text-secondary); }
.datahub-tenant-processing-dot[data-status="processing"],
.datahub-tenant-processing-dot[data-status="reference-update"],
.datahub-tenant-processing-dot[data-status="error"] { background: var(--state-offline); }
.datahub-tenant-select { position: absolute; inset: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto auto; align-items: start; width: 100%; min-width: 0; padding: var(--space-5) var(--space-3) var(--space-3); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.datahub-tenant-select:focus-visible, .datahub-tenant-add:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
@ -63,6 +64,9 @@
.datahub-tenant-show[data-status="review"],
.datahub-tenant-show[data-status="review"]:hover,
.datahub-tenant-show[data-status="review"]:active { border-color: var(--warning); color: var(--warning); background: color-mix(in srgb, var(--warning) 8%, var(--donut-card-surface)); }
.datahub-tenant-show[data-status="reference-update"],
.datahub-tenant-show[data-status="reference-update"]:hover,
.datahub-tenant-show[data-status="reference-update"]:active,
.datahub-tenant-show[data-status="error"],
.datahub-tenant-show[data-status="error"]:hover,
.datahub-tenant-show[data-status="error"]:active { border-color: var(--state-offline); color: var(--state-offline); background: color-mix(in srgb, var(--state-offline) 8%, var(--donut-card-surface)); }

View File

@ -157,7 +157,7 @@
/* DB-Struktur und Ergebnisse arbeiten mit vielen Spalten/Karten und sollen die
volle verfügbare Breite nutzen, statt wie die übrigen Datahub-Unterseiten auf
min(1180px, 100%) begrenzt zu sein. */
.db-structure-page > *, .results-browser-page > * { width: 100% !important; max-width: none !important; margin-inline: 0 !important; }
.results-browser-page > * { width: 100% !important; max-width: none !important; margin-inline: 0 !important; }
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
Verteilungen bleiben direkte Einstiege in Work mit passendem Filter. */
@ -622,6 +622,19 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.client-domains li > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-hairline); }
.client-domain-amount-bar { position: relative; z-index: 0; grid-column: 3; grid-row: 1; align-self: center; width: 100%; height: var(--space-6); overflow: hidden; pointer-events: none; }
.client-domain-amount-bar > i { position: absolute; inset-block: 0; right: 0; width: var(--share); background: var(--chart-color, var(--client-info-neutral)); }
.client-business-impact { margin-top: var(--space-6); }
.client-business-impact h3 { margin: 0 0 var(--space-3); color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-business-impact p { margin: 0; font-size: var(--type-table-body-size); }
.client-business-impact-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-2); }
.client-business-impact-column { display: grid; grid-template-rows: auto auto 1fr; gap: var(--space-2); min-width: 0; width: 100%; height: auto; padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--client-info-text); text-align: center; font-weight: var(--fw-regular); cursor: pointer; }
.client-business-impact-column:hover:not(:disabled) { background: var(--button-bg-hover); }
.client-business-impact-column:focus-visible { outline: var(--marker-width) solid var(--focus-ring); outline-offset: var(--space-1); }
.client-business-impact-column:disabled { opacity: 1; cursor: default; }
.sidebar-business-impact { margin-top: var(--space-4); padding-top: var(--space-3); border-top: 1px solid var(--line); }
.client-business-impact-column strong { color: var(--client-info-text); font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.client-business-impact-track { display: flex; align-items: flex-end; height: calc(var(--space-8) * 3); }
.client-business-impact-track i { display: block; width: 100%; height: var(--share); background: var(--chart-tone-emphasis); }
.client-business-impact-label { color: var(--client-info-text); font-size: var(--type-table-body-size); line-height: var(--lh-ui); overflow-wrap: anywhere; }
.client-domain-score { z-index: 1; grid-column: 1; grid-row: 1; display: grid; place-items: center; width: 40px; height: 40px; border: var(--border-hair) solid var(--chart-color, var(--client-info-line-strong)); border-radius: 50%; color: var(--client-info-text); background: var(--client-info-surface); }
.client-domain-score .client-glyph { width: var(--space-6); height: var(--space-6); }
.client-domain-score em { color: var(--chart-color, var(--client-info-text)); font-size: var(--type-h4-size); font-style: normal; font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; line-height: 1; }
@ -633,7 +646,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.client-priority { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: stretch; align-items: stretch; gap: var(--segment-gap); min-width: 0; }
.client-priority > section { padding: var(--tile-inset); }
.client-domain-information dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--border-hair); margin: var(--space-3) 0 0; padding: var(--border-hair); background: var(--client-info-line); }
.client-domain-information > h2 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-domain-information > header h2 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-domain-information dl > div { display: grid; align-content: start; gap: var(--space-1); min-width: 0; padding: var(--space-2); background: var(--tile-surface); }
.client-domain-information dl > div:first-child { grid-column: 1 / -1; }
.client-domain-information dt { color: var(--client-info-muted); font-size: var(--type-table-body-size); font-weight: var(--fw-regular); line-height: 1.3; }
@ -650,7 +663,11 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.client-priority-context strong { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.client-priority-context span { color: var(--client-info-muted); font-size: var(--type-table-body-size); line-height: 1.2; text-align: center; }
.client-data-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); min-width: 0; margin-top: var(--space-3); border-top: var(--border-hair) solid var(--client-info-line); border-bottom: var(--border-hair) solid var(--client-info-line); }
.client-data-fact { display: grid; grid-template-columns: var(--space-7) minmax(0, 1fr); align-items: center; gap: var(--space-2); min-width: 0; min-height: 64px; padding: var(--space-2); color: var(--client-info-muted); background: transparent; }
.client-data-fact { display: grid; grid-template-columns: var(--space-7) minmax(0, 1fr); align-items: center; gap: var(--space-2); min-width: 0; min-height: 64px; padding: var(--space-2); color: var(--client-info-muted); background: transparent; border: 0; border-radius: var(--radius-sm); font-family: inherit; text-align: left; cursor: pointer; }
.client-data-fact:hover { background: var(--surface-hover); }
.client-data-fact:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.client-data-fact-evidence { width: 100%; border-collapse: collapse; font-size: var(--type-table-body-size); }
.client-data-fact-evidence th, .client-data-fact-evidence td { padding: var(--space-2); border-bottom: var(--bw-hairline) solid var(--border-default); text-align: left; overflow-wrap: anywhere; font-size: var(--type-table-body-size); }
.client-data-fact + .client-data-fact { border-left: var(--border-hair) solid var(--client-info-line); }
.client-data-fact:nth-child(4) { border-left: 0; }
.client-data-fact:nth-child(n + 4) { border-top: var(--border-hair) solid var(--client-info-line); }
@ -819,7 +836,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
}
/* DB-Struktur panel */
.db-structure-page { width: 100%; max-width: none; }
.db-structure-page { --db-structure-content-width: calc(var(--tile-content-width) * 3 + var(--space-8) * 6); width: 100%; max-width: none; }
.db-structure-page > * { width: 100%; max-width: var(--db-structure-content-width); margin-inline: auto; }
/* Der Abstand nach oben gehoert der Zeile, nicht dem Text darin: die Metazeile
steht auf jeder Seite in einer .page-actions-row und braucht daher keinen
eigenen Aussenabstand. */
@ -1868,9 +1886,13 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
/* DB Structure data flow (user request 2026-09-25): program-flow view from upload to Work.
Steps are flat tiles; connectors are neutral hairlines; selection uses the shared marking role. */
.db-flow { --db-flow-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, .8fr); --db-flow-gap: var(--space-5); display: grid; gap: var(--db-flow-gap); min-width: 0; margin: var(--space-5) 0; }
.db-flow { --db-flow-columns: repeat(3, minmax(0, 1fr)); --db-flow-gap: calc(var(--space-8) * 3); position: relative; display: grid; gap: var(--db-flow-gap); width: 100%; min-width: 0; margin: var(--space-5) auto; }
.db-flow__edges { z-index: 2; }
.db-flow__edge-label rect { fill: var(--surface-app); }
.db-flow__edge-label text { fill: var(--muted); font-size: var(--type-table-body-size); }
.db-flow__edges marker path.is-related, .db-flow__edge-label.is-related text { fill: var(--marking-color); }
.db-flow__header h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.db-engine-flow { display: grid; gap: var(--space-4); margin: var(--space-5) 0; min-width: 0; }
.db-engine-flow { display: grid; gap: var(--space-4); margin: var(--space-5) auto; min-width: 0; }
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
@ -1881,7 +1903,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__edges > path.is-planned { stroke-dasharray: 6 5; }
.db-engine-flow__edges marker path { fill: var(--line-strong); }
.db-engine-flow__node { position: absolute; display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); width: 220px; min-height: 100px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: 0; background: var(--tile-surface); color: var(--text); text-align: left; cursor: pointer; }
.db-engine-flow__node.is-engine { border-left: var(--marker-width) solid var(--line-strong); }
.db-engine-flow__node.is-engine { outline: var(--bw-emphasis) solid var(--muted); outline-offset: -2px; border-left: var(--marker-width) solid var(--muted); }
.db-engine-flow__node.is-related { border-color: var(--line-strong); }
.db-engine-flow__node[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: -2px; border-left-color: var(--marking-color); }
.db-engine-flow__node:hover { background: var(--tile-surface-hover); }
@ -1921,32 +1943,37 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow th, .db-engine-flow td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: var(--border-hair) solid var(--line); }
.db-engine-flow th { font-weight: var(--fw-semibold); }
@media (max-width: 1050px) { .db-engine-flow__inspector { grid-template-columns: minmax(0, 1fr); } }
.db-flow__step { position: relative; z-index: 1; display: grid; align-content: start; gap: var(--space-2); min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); background: var(--tile-surface); }
.db-flow__step { position: relative; z-index: 1; display: grid; align-content: start; justify-self: center; gap: var(--space-2); width: 100%; max-width: var(--tile-content-width); min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-left: var(--marker-width) solid var(--line-strong); border-radius: 0; background: var(--tile-surface); }
.db-flow__step[data-selected="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); border-left-color: var(--marking-color); }
.db-flow__step { cursor: pointer; }
.db-flow__select { display: flex; align-items: baseline; gap: var(--space-2); padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.db-flow__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); }
.db-flow__step > header { display: flex; align-items: baseline; gap: var(--space-2); }
.db-flow__step h4 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.db-flow__step > p { margin: 0; color: var(--muted); font-size: var(--type-table-body-size); line-height: 1.45; }
.db-flow__step > p.db-flow__note { color: var(--text); font-weight: var(--fw-semibold); }
.db-flow__number { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 var(--space-1); border: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-badge-size); font-weight: var(--fw-semibold); }
.db-flow__tables { display: flex; flex-wrap: wrap; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
.db-flow__table { display: inline-flex; align-items: baseline; gap: var(--space-1); padding: 3px var(--space-2); border: var(--border-hair) solid var(--line); border-radius: var(--radius-xs); color: var(--text); background: var(--surface-inset); font-size: var(--type-table-body-size); cursor: pointer; }
.db-flow__tables > li { min-width: 0; max-width: 100%; }
.db-flow__table { display: inline-flex; align-items: baseline; gap: var(--space-1); max-width: 100%; padding: 3px var(--space-2); border: var(--border-hair) solid var(--line); border-radius: var(--radius-xs); color: var(--text); background: var(--surface-inset); font-size: var(--type-table-body-size); overflow-wrap: anywhere; cursor: pointer; }
.db-flow__table:hover { background: var(--surface-hover); }
.db-flow__table[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: -1px; background: var(--surface-selected); color: var(--heading); font-weight: var(--fw-semibold); }
.db-flow__table[data-related] { border-color: var(--line-strong); color: var(--heading); font-weight: var(--fw-semibold); }
.db-flow__rows { color: var(--muted); font-size: var(--type-meta-size); font-weight: var(--fw-regular); }
/* Vertical connector below a shared step. */
.db-flow__step.is-continued::after { content: ""; position: absolute; left: 50%; top: 100%; height: var(--db-flow-gap); border-left: var(--border-hair) solid var(--line-strong); }
.db-flow__step.is-continued::after { content: none; }
.db-flow__split, .db-flow__merge { display: grid; grid-template-columns: var(--db-flow-columns); column-gap: var(--db-flow-gap); height: 0; margin: calc(-1 * var(--db-flow-gap)) 0; }
.db-flow__split > span, .db-flow__merge > span { position: relative; }
.db-flow__split > span:nth-child(-n+2)::after, .db-flow__merge > span:nth-child(-n+2)::after { content: ""; position: absolute; left: 50%; top: 0; height: var(--db-flow-gap); border-left: var(--border-hair) solid var(--line-strong); }
.db-flow__split > span:nth-child(-n+2)::after, .db-flow__merge > span:nth-child(-n+2)::after { content: none; }
.db-flow__lanes { display: grid; grid-template-columns: var(--db-flow-columns); column-gap: var(--db-flow-gap); align-items: stretch; min-width: 0; }
.db-flow__lane { position: relative; display: flex; flex-direction: column; gap: var(--db-flow-gap); min-width: 0; }
.db-flow__lane > .db-flow__step { align-self: center; }
/* Lane spine: one continuous flow line behind the steps of a processing path. */
.db-flow__lane.is-scanner::before, .db-flow__lane.is-gpo::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; border-left: var(--border-hair) solid var(--line-strong); }
.db-flow__lane.is-scanner > .db-flow__step + .db-flow__step::before, .db-flow__lane.is-gpo > .db-flow__step + .db-flow__step::before { content: ""; position: absolute; left: calc(50% - 5px); top: -7px; border: 5px solid transparent; border-top-color: var(--line-strong); border-bottom: 0; }
.db-flow__lane.is-reference { justify-content: center; }
.db-flow__lane.is-scanner::before, .db-flow__lane.is-gpo::before { content: none; }
.db-flow__lane.is-scanner > .db-flow__step + .db-flow__step::before, .db-flow__lane.is-gpo > .db-flow__step + .db-flow__step::before { content: none; }
.db-flow__lane.is-reference { justify-content: flex-start; }
.db-flow__lane-title { position: relative; z-index: 1; align-self: center; margin: 0; padding: 0 var(--space-2); color: var(--muted); background: var(--panel); font-size: var(--type-table-head-size); font-weight: var(--fw-semibold); text-transform: uppercase; letter-spacing: var(--ls-caps); }
.db-flow__lane.is-reference > .db-flow__lane-title { align-self: start; padding: 0; }
.db-flow__step.is-reference { border-left: var(--bw-emphasis) solid var(--line-strong); }
.db-flow__step.is-reference { border-left-width: var(--marker-width); }
.db-flow__detail { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); align-items: start; }
.db-flow__links { display: grid; gap: var(--space-2); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); background: var(--tile-surface); }
.db-flow__links h4 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
@ -1958,6 +1985,25 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-flow__split, .db-flow__merge { display: none; }
.db-flow__lanes { row-gap: var(--db-flow-gap); }
}
.db-flow__consumers { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--db-flow-gap); align-items: start; min-width: 0; }
.db-flow__consumers > .db-flow__step[aria-label="Result DB"] { border-left: var(--bw-emphasis) solid var(--line-strong); }
.db-flow__consumers { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--db-flow-gap); align-items: start; width: 100%; max-width: calc(var(--tile-content-width) + var(--tile-content-width) + var(--db-flow-gap)); justify-self: center; min-width: 0; }
.db-flow__consumers > .db-flow__step[aria-label="Result DB"] { border-left-width: var(--marker-width); }
@media (max-width: 1050px) { .db-flow__consumers { grid-template-columns: minmax(0, 1fr); } }
.client-information-change { display: grid; gap: var(--space-3); }
.client-information-change p { margin: 0; font-size: var(--type-body-size); line-height: 1.6; }
.client-information-change table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.client-information-change th, .client-information-change td { text-align: left; vertical-align: top; padding: var(--space-2); border-bottom: var(--bw-hairline) solid var(--border-default); overflow-wrap: anywhere; }
.client-information-change th { font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
.client-information-change td { font-size: var(--type-body-size); }
.client-information-change tbody th { text-transform: none; letter-spacing: normal; }
.client-information-browser { display: grid; gap: var(--space-4); min-width: 0; }
.client-information-browser p { margin: 0; font-size: var(--type-body-size); }
.client-information-filters { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }
.client-information-filters label { display: grid; gap: var(--space-1); font-size: var(--type-table-body-size); }
.client-information-filters input, .client-information-filters select { min-width: 0; max-width: 100%; height: var(--btn-h); padding: 0 var(--space-2); border: var(--border-hair) solid var(--border-default); border-radius: var(--btn-radius); background: var(--surface-input); color: var(--text-primary); font-size: var(--type-table-body-size); }
.client-information-table { width: 100%; border-collapse: collapse; }
.client-information-table th, .client-information-table td { text-align: left; vertical-align: top; padding: var(--space-2); border-bottom: var(--bw-hairline) solid var(--border-default); overflow-wrap: anywhere; font-size: var(--type-table-body-size); }
.client-information-table td:nth-child(2) { font-size: var(--type-body-size); }
.client-information-detail { display: grid; gap: var(--space-3); min-width: 0; }
.client-information-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-ui); font-size: var(--type-table-body-size); }
.client-information-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); font-size: var(--type-table-body-size); }
.client-information-browser .client-information-provenance { font-size: var(--type-meta-size); color: var(--text-muted); }

View File

@ -0,0 +1,14 @@
.filter-empty-state { display: grid; justify-items: center; align-content: center; gap: var(--space-3); padding: var(--panel-inset); text-align: center; color: var(--text-secondary); }
.filter-empty-state h3 { margin: 0; color: var(--text-primary); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.filter-empty-state p { margin: 0; max-width: var(--activity-panel-width); font-size: var(--type-body-size); line-height: 1.5; }
.filter-empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }
.filter-search-figure { width: var(--filter-search-figure-size); height: auto; overflow: visible; color: var(--text-secondary); }
.filter-search-body { fill: currentColor; }
.filter-search-eyes { fill: var(--surface-panel); }
.filter-search-mouth { fill: none; stroke: var(--surface-panel); stroke-width: 2; stroke-linecap: square; }
.filter-search-lens { fill: var(--surface-panel); stroke: currentColor; stroke-width: 3; stroke-linecap: square; }
.filter-search-lens path { fill: none; }
.filter-search-glint { opacity: .5; }
.filter-search-look { transform-origin: center; animation: filter-search-look 3.6s ease-in-out infinite; }
@keyframes filter-search-look { 0%, 100% { transform: translateX(-2%) rotate(-3deg); } 50% { transform: translateX(2%) rotate(3deg); } }
@media (prefers-reduced-motion: reduce) { .filter-search-look { animation: none; } }

View File

@ -298,7 +298,7 @@
.obsidian-inspector-close { position: absolute; top: var(--space-2); right: var(--space-2); display: grid; place-items: center; width: var(--control-h); height: var(--control-h); padding: 0; border: 0; color: var(--text-secondary); background: transparent; font-size: var(--fs-lg); cursor: pointer; }
.obsidian-graph-empty { position: absolute; inset: 0; display: grid; place-content: center; gap: var(--space-2); color: var(--text-secondary); text-align: center; pointer-events: none; }
.obsidian-graph-empty strong, .obsidian-graph-empty span { font-size: var(--fs-lg); }
.obsidian-graph-empty .filter-empty-actions { pointer-events: auto; }
.obsidian-graph-card-zoom-hint {
position: absolute;
left: 50%;

View File

@ -27,6 +27,7 @@
Rohansichten verwenden dieselbe Familie; Hierarchie entsteht nicht durch
wechselnde Schriften, sondern ausschliesslich durch Groesse und Gewicht. */
--activity-panel-width: 560px;
--filter-search-figure-size: 88px;
--pathfinder-details-width: clamp(270px, 21vw, 340px);
--pathfinder-stage-min-width: 360px;

View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>ASSESSOR — Synthetic activity acceptance preview</title></head>
<body><p>Synthetic acceptance preview · Shared production Activity component</p><div id="root"></div>
<script type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/inter/wght.css';
import '/src/styles/layers.css';
import '/src/styles/ui-tokens.css';
import '/src/styles/tokens.css';
import '/src/styles/base.css';
import '/src/styles/components.css';
import { ActivityCenter } from '/src/components/ActivityCenter.tsx';
import { beginActivity } from '/src/platform/activity-store.ts';
const query = new URLSearchParams(location.search);
document.documentElement.dataset.theme = query.get('theme') === 'light' ? 'light' : 'dark';
for(let i=0;i<3;i++) beginActivity('Core Engine','Loading reference recognition').complete();
for(let i=0;i<3;i++) beginActivity('Core Engine','Loading Result DB filters').complete();
if(query.get('state') === 'incomplete') {
beginActivity('Core Engine','Loading client').fail();
} else if(query.get('state') !== 'completed') {
beginActivity('Report Engine','Generating report').fail();
beginActivity('Core Engine','Opening client');
beginActivity('Context Engine','Loading context').queue();
}
createRoot(document.getElementById('root')).render(React.createElement(ActivityCenter));
</script></body></html>

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.11.2');
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.16.5');
expect(app).toContain("'db-structure': 'Data Flow'");
expect(app).toContain("['db-structure', 'Data Flow']");
expect(app).not.toContain("'DB Structure'");

View File

@ -55,7 +55,9 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
* readers), the type-only API contract and the server wiring of both engines.
*/
const EXTENSION_POINTS: Record<string, { engine: string; typeOnly?: boolean }> = {
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine'] },
'src/api/assessor-api-contract.ts': { engine: 'Context Engine', typeOnly: true },
'src/features/actionlist/ActionlistControls.tsx': { engine: 'Context Engine' },
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
@ -100,7 +102,7 @@ describe('Core Engine and engines boundary', () => {
const engine = engineOf(use.target);
if (!engine) return [];
const allowed = EXTENSION_POINTS[posix(file)];
const ok = allowed && (allowed.engine === 'any' || allowed.engine === engine) && (!allowed.typeOnly || use.typeOnly);
const ok = allowed && (allowed.engine === 'any' || allowed.engine === engine || (Array.isArray(allowed.engine) && allowed.engine.includes(engine))) && (!allowed.typeOnly || use.typeOnly);
return ok ? [] : [`${posix(file)} -> ${use.specifier} (${engine}${use.typeOnly ? ', type' : ''})`];
}));
expect(violations).toEqual([]);

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