From 47c23a8c6ed996c0a4fc75a6de09415d56c268a6 Mon Sep 17 00:00:00 2001 From: Binh Date: Tue, 6 Oct 2026 16:03:29 +0200 Subject: [PATCH] feat(compliance): update compliance UI and logic for ISO controls - Refactor compliance CSS to enhance layout and styling for the new Compliance row. - Update RESULT_DB_DISCLOSURE_POLICY_VERSION to 3.8.0 for consistency across modules. - Implement rollup logic for policy comparisons in result DB materialization. - Enhance DashboardPanel to support theme filtering alongside control filtering. - Introduce new ClientTileParts component for shared dashboard elements. - Add tests for result DB materialization to ensure correct handling of GPO comparisons. - Update existing tests to reflect changes in compliance logic and UI. --- AGENTS.md | 4 +- docs/IMPLEMENTATION_PLAN.md | 6 + src/compliance/ui/ClientIsoControls.tsx | 115 ++++++++++++++---- src/compliance/ui/compliance.css | 62 ++++++---- src/domain/result-db-disclosure.ts | 2 +- src/domain/result-db-materialization.ts | 70 ++++++++++- src/features/dashboard/ClientTileParts.tsx | 29 +++++ src/features/dashboard/DashboardPanel.tsx | 38 ++---- src/styles/datahub.css | 11 ++ tests/compliance/client-iso-controls.test.tsx | 84 ++++++++++--- .../domain/result-db-materialization.test.ts | 88 ++++++++++++++ tests/exports/unified-result-jsonl.test.ts | 2 +- 12 files changed, 424 insertions(+), 87 deletions(-) create mode 100644 src/features/dashboard/ClientTileParts.tsx create mode 100644 tests/domain/result-db-materialization.test.ts diff --git a/AGENTS.md b/AGENTS.md index aebb7c6..04e021a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ ## Core Engine and engines - Explicit user decision 2026-10-01: ASSESSOR with all its existing functions is the **Core Engine**. The context enrichment (Phase 13E, "context engine"/"Kontext-Engine" in earlier text) is the **Context Engine**. The read-only Report Engine (Phase 7, `src/reporting/`, `server/reporting/`) is an engine in the same sense. Future features arrive as further engines that provide a capability without changing the Core. The UI labels `Context`/`Kontext` stay unchanged. -- Explicit user decision 2026-10-05: the regulatory control mapping (Phase 13F, ISO mapping) is the **Compliance Engine** (`src/compliance/`, `server/compliance/`, `tests/compliance/`). It is framework-neutral by intent: ISO/IEC 27001/27002 is its first corpus, later frameworks such as BSI or DORA arrive inside this engine without a Core change. It reads the Core only through `ComplianceReadPort` (single Result DB row by project, filtered Result DB pages, project, assessment and scan-run lists; explicit user decision 2026-10-05: additionally the internal-analysis `policy-evaluation` rows of the same project and timeslot, used solely to read the GPO technical setting ID that the Result DB withholds, so a GPO setting counts once like its rolled-up Work record and the Work ISO facets match it; that ID is never displayed or exported and no other internal field is read) and stores mapping runs, accept/reject decisions, batches and AI overview runs only through its own `ComplianceStore`. Explicit user decision 2026-10-05: all AI integration belongs to this engine. The AI overview (service, model, Client Info panel) moved from the Core into it, and the Result DB `Export JSON` is the engine's 1:1 copy of what its AI features receive: the exact AI overview input built by the same function the overview sends to the model, and every distinct ISO mapping query with the Result DB rows that produce it (the retrieved candidate controls depend on the index and are not exported). The shared model client `server/ai/litellm-client.ts` stays Core platform code. The existing tables of migrations 025 (AI overview runs) and 026 keep their names and data and are owned by the engine; its writes never advance the Core repository revision. Because the Core deletes scan runs and assessments without knowing the engine, the engine removes AI overview runs of a deleted timeslot from its own table on start and on read. The Core project export receives the engine data, including succeeded AI overview runs, beside the Core export at the HTTP route. Its extension points are the Finding Reader `Map ISO controls` action and its window, the Finding Reader `ISO controls` section, the Result DB `Map all ISO controls` batch action and `Export JSON` action, the Data Hub ISO corpus tile, the Client Info `ISO controls` section and `AI Overview` tab, and the Work `ISO control` and `ISO theme` facets. Switched off, the engine also removes the AI overview tab and its requests and the `Export JSON` action. Explicit user decision 2026-10-05: `Map ISO controls` opens a window like `Accepted Risk` with Accept and Reject per control (no third decision value). The Finding Reader lists the controls whose latest decision is accepted in an `ISO controls` section read like `Description`, after the summary fields. Client Info shows the accepted controls of the selected timeslot directly below the Context heatmap in the same tile layout without severity colour, each with its ISO 27002 theme and the number of deviation findings (a GPO setting counts once); a tile opens Work with the `Deviation` status and that control selected in the `ISO control` facet, like a Context heatmap cell. Explicit user decision 2026-10-05: Work offers the Compliance Engine facets `ISO control` (one value per accepted control, `A.8.20 · Networks security`) and `ISO theme` (Organizational, People, Physical, Technological) in mixed Work and in both GPO entry paths, directly before the permanent final `Source: Scan`; this extends the GPO-only facet list. They read the accepted controls of every mappable finding of the timeslot (scanner findings by occurrence, GPO settings by scope and technical identity), offer only values with hits, count against the other active filters, keep an active selection while loading, and disappear with their filter cleared when the engine is off. Decisions stay global per scanner rule. Provisional explicit user decision 2026-10-05 (may change later): after a mapping succeeds, single or batch, the engine accepts its first two proposals in the model's order automatically, only the one if there is a single proposal, with the comment `Accepted automatically: one of the first two proposals of the mapping.`; a control that already has a decision keeps it, so a manual reject is never overridden, and a failure of this step never fails the mapping. +- Explicit user decision 2026-10-05: the regulatory control mapping (Phase 13F, ISO mapping) is the **Compliance Engine** (`src/compliance/`, `server/compliance/`, `tests/compliance/`). It is framework-neutral by intent: ISO/IEC 27001/27002 is its first corpus, later frameworks such as BSI or DORA arrive inside this engine without a Core change. It reads the Core only through `ComplianceReadPort` (single Result DB row by project, filtered Result DB pages, project, assessment and scan-run lists; explicit user decision 2026-10-05: additionally the internal-analysis `policy-evaluation` rows of the same project and timeslot, used solely to read the GPO technical setting ID that the Result DB withholds, so a GPO setting counts once like its rolled-up Work record and the Work ISO facets match it; that ID is never displayed or exported and no other internal field is read) and stores mapping runs, accept/reject decisions, batches and AI overview runs only through its own `ComplianceStore`. Explicit user decision 2026-10-05: all AI integration belongs to this engine. The AI overview (service, model, Client Info panel) moved from the Core into it, and the Result DB `Export JSON` is the engine's 1:1 copy of what its AI features receive: the exact AI overview input built by the same function the overview sends to the model, and every distinct ISO mapping query with the Result DB rows that produce it (the retrieved candidate controls depend on the index and are not exported). The shared model client `server/ai/litellm-client.ts` stays Core platform code. The existing tables of migrations 025 (AI overview runs) and 026 keep their names and data and are owned by the engine; its writes never advance the Core repository revision. Because the Core deletes scan runs and assessments without knowing the engine, the engine removes AI overview runs of a deleted timeslot from its own table on start and on read. The Core project export receives the engine data, including succeeded AI overview runs, beside the Core export at the HTTP route. Its extension points are the Finding Reader `Map ISO controls` action and its window, the Finding Reader `ISO controls` section, the Result DB `Map all ISO controls` batch action and `Export JSON` action, the Data Hub ISO corpus tile, the Client Info `Compliance` row (formerly the `ISO controls` section) and `AI Overview` tab, and the Work `ISO control` and `ISO theme` facets. Switched off, the engine also removes the AI overview tab and its requests and the `Export JSON` action. Explicit user decision 2026-10-05: `Map ISO controls` opens a window like `Accepted Risk` with Accept and Reject per control (no third decision value). The Finding Reader lists the controls whose latest decision is accepted in an `ISO controls` section read like `Description`, after the summary fields. Client Info shows the accepted controls of the selected timeslot directly below the Context heatmap in the same tile layout without severity colour, each with its ISO 27002 theme and the number of deviation findings (a GPO setting counts once); a tile opens Work with the `Deviation` status and that control selected in the `ISO control` facet, like a Context heatmap cell. Explicit user decision 2026-10-06 supersedes only the tile layout of that Client Info section, after feedback that it grows too large: the accepted controls are grouped by ISO 27002 theme in ISO order (Organizational, People, Physical, Technological; a theme without hits is omitted). Each full-width theme heading states its number of controls and of distinct findings (a finding counts once per theme) and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet. Below it, its controls flow in columns of twice the compact tile width, one compact row per control sorted by finding count, showing the Annex A ID, the complete title (wrapped, never truncated) and the authoritative count, without an amount bar (removed the same day because it cost title width); a row opens Work exactly like the former tile. A theme shows its top six controls; a keyboard-accessible `Show all N controls` button below expands that theme and `Show top 6` collapses it again, and every theme starts collapsed for a new timeslot. The theme heading still opens all of its controls in Work. Explicit user decision 2026-10-06, later the same day, supersedes the in-page list and its top-six cap: Client Info shows a `Compliance` row below the Context heatmap with one tile per framework, in the exact environment tile geometry and fact grid (its own `client-compliance-grid` with the same tile rules; the environment row keeps exactly its six tiles), so later frameworks such as BSI or DORA add a tile instead of a section. The `ISO 27001/27002` tile shows `Controls` (accepted controls), `Findings` (distinct findings) and the two themes with the most findings, each with its finding count (fewer when fewer themes have hits; an em dash while loading, never zero). Its `Results` button opens the shared Results window with the complete grouped list described above, without a cap; a theme heading or row there closes the window and opens Work as described. Explicit user decision 2026-10-06, again later the same day, supersedes the two-theme selection of the tile: after `Controls` and `Findings` the tile shows all four themes in ISO order (Organizational, People, Physical, Technological), each with its finding count (`0 findings` without hits, an em dash while loading). A theme with hits is one click target over its whole fact cell and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet; a theme without hits is not clickable, because the facet offers only values with hits. Hover changes only the cell surface; keyboard focus shows the blue focus ring. All six facts stay visible in this tile at every width, unlike the environment tiles, which show two facts below their measured four-fact width. Explicit user decision 2026-10-05: Work offers the Compliance Engine facets `ISO control` (one value per accepted control, `A.8.20 · Networks security`) and `ISO theme` (Organizational, People, Physical, Technological) in mixed Work and in both GPO entry paths, directly before the permanent final `Source: Scan`; this extends the GPO-only facet list. They read the accepted controls of every mappable finding of the timeslot (scanner findings by occurrence, GPO settings by scope and technical identity), offer only values with hits, count against the other active filters, keep an active selection while loading, and disappear with their filter cleared when the engine is off. Decisions stay global per scanner rule. Provisional explicit user decision 2026-10-05 (may change later): after a mapping succeeds, single or batch, the engine accepts its first two proposals in the model's order automatically, only the one if there is a single proposal, with the comment `Accepted automatically: one of the first two proposals of the mapping.`; a control that already has a decision keeps it, so a manual reject is never overridden, and a failure of this step never fails the mapping. - Engines may read the Core without restriction but never write to, change or override it. They read only through a declared read-only port and the read-only database access, never through Core tables or internal modules directly. What an engine shows or exports still obeys the Core's privacy and Result DB disclosure rules. - Dependencies point one way: an engine depends on the Core, the Core never depends on an engine. The Core works unchanged with every engine switched off; each engine keeps a permanent on/off invariance test like the Context layer's. Engines do not build on each other except through another engine's published results. - An engine that needs storage gets its own schema or tables after separate approval with backup and migration, never columns in Core tables. An engine cannot create findings: new scanner profiles, importers and parsers write Core data and are always Core changes. @@ -149,7 +149,7 @@ - Binding user requirement 2026-09-28 (supersedes the 2026-09-25 decision to retain the concrete GPO Istwert, exact scanner values and baseline releases): the Result DB (UI, API and JSONL) must be privacy-compliant and must not provide an attack guide. It carries no value that fingerprints the assessed environment or calibrates attack effort. Retain the public rule ID when validated, tool, result/severity/outcome, result type, deviation/IOE, public scanner guidance, the GPO technical setting ID, Sollwert, comparison relation and baseline family. Withhold the GPO customer value (Istwert) and every principal in it, customer-value placeholders, GPO source counts, concrete baseline releases (only the family, for example `Microsoft Security Baseline - Windows Server`), client-owned review/priority/risk-acceptance state and supplemental counts, scores, functional levels and versions (only binary configuration states cross). In non-benchmark scanner prose, exact counts, dates, times and durations become `[count]`, `[date]`, `[time]` and `[duration]`; public technical tokens (dotted versions, CVE/KB/ATT&CK/rule IDs, product names, ports, event IDs, public SIDs) stay. Explicit user correction 2026-09-28: redaction must not damage public guidance. Numbered steps (`1.`, `2)`) stay; the recommendation field alone keeps vendor thresholds (`at least 8 characters`, `more than 180 days`, `15 or more`), while titles and descriptions stay fully redacted. Group Policy and registry paths (`Computer Configuration\...`, `HKLM\...`, `System\CurrentControlSet\...`, GP node paths, hard-wrapped path lines) and documented Windows authorities (`BUILTIN`, `NT AUTHORITY`, `NT SERVICE`, `NT VIRTUAL MACHINE`, `RESTRICTED SERVICES`, `Window Manager`, `Font Driver Host`) are not accounts. Only standalone IPv4 addresses are redacted: never part of a longer dotted sequence, a `Rule` reference or a CIS rule number, and never a documented range boundary (RFC 1918, loopback, link-local and the public space around RFC 1918). Explicit user correction 2026-09-28 (policy 3.2.0): inline ordered lists (a run 1, 2, 3 ... of at least two items, tolerating one repeated or skipped source number) and documented attribute flags (`adminCount=1`) stay; `set to N` is a retained recommendation value only in the recommendation field. Vendor and standards documentation links and host names stay (microsoft.com and cloud.microsoft, azure.com, office.com, windows.com, aka.ms, github.com, maester.dev, cisa.gov, dhs.gov, whitehouse.gov, cisecurity.org, mitre.org, nist.gov, pingcastle.com, semperis.com, citrix.com, owasp.org, ietf.org, rfc-editor.org, bsi.bund.de) unless the link carries an `@`, a tenant marker (`onmicrosoft`, `sharepoint`), a private literal or another non-public host. Tenant hosts (`*.onmicrosoft.com`, `*.sharepoint.com`, `login.microsoftonline.com/`) and every other host stay redacted. Document/program names (`pfirewall.log`), ScubaGear IDs (`MS.AAD.2.3`), Microsoft Graph permissions, `System.*`/`Microsoft.*`/`Windows.*` namespaces, PascalCase code identifiers, code property access and sentence joins with a listed sentence starter are not domains. CIS-CAT prose is benchmark text and keeps its public recommendation values. Identical baseline expectations are collapsed so their count does not reveal compared releases; the JSONL carries only the export date and the `confidential-security-posture` handling marker. ATT&CK/D3FEND references and Pathfinder relations never enter the Result DB. Every projection field is classified in `RESULT_DB_FIELD_DISCLOSURE`; unclassified fields never cross. Explicit user correction 2026-09-29 (policy 3.3.1): per-finding scanner scores are withheld because they scale with the affected objects; client-owned review fields never appear in the trace's withheld-field list, so a row does not reveal whether it was triaged; CIS numbers keep all seven levels; `Limited` is not a legal form; names with attached versions (`Tls1.2`, `v1.0`), `Windows 95`, `2012-R2` and `step N`, `Option N` and `Page N` references stay; documented Microsoft GUIDs from the public catalog (ASR rules, first-party application IDs, built-in role templates) stay while every other GUID remains `[id]`. Explicit user correction 2026-09-29 (policy 3.4.0): the recommendation field alone also keeps documented vendor values: `set to N` within one sentence without other numbers, `value is N`, `the number/value/flag N`, `assign N`, `N as the Value`, `N for mode`, attribute filters such as `admincount=1`, `zero (0)`, `N+`, `N methods/ways/options` and `only N will be listed`. Titles and descriptions stay fully redacted, and an observed count in a recommendation remains `[count]`. Binding user requirement 2026-09-28: the Result DB is checked and stored when it is created (materialized table `assessor_result_db_rows`, rebuilt after start and after every committed change; reads wait for a current state and never serve an outdated one), so UI, API, single reads and JSONL show exactly the same stored content. Each row carries a content-free trace (field, rule, count, withheld fields; never an original value), shown in the row detail with `Open original in Work`; the Result DB never shows originals. A per-client identity guard removes the client's own names (client name, domain/forest/host/tenant properties, account forms, identity-shaped assets, review owners) from every row and withholds a field when such a name survives in another spelling; report vocabulary, registry paths, public groups and template names are never identities. Concrete person, account, tenant, customer and system identities remain excluded. Keep complete originals in Work and internal analysis. - Explicit user correction 2026-09-20: in linked CIS-CAT Results evidence, an evaluated source row must follow the actual finding outcome. Highlight the confirmed configured row with the shared green match treatment when the finding outcome is `Pass`, and use the red violation treatment only when the outcome is `Fail`; unknown, neutral or unavailable outcomes must not be guessed into either state. The criterion row remains the separately identified expected state. -- Explicit user correction 2026-09-18: Result DB uses the shared Work vocabulary for its GPO `Result Status` filter and details: `Deviation`, `Passed`, `Not configured`, `Conflicts`, and `Duplicates`; `Review required` remains an independent unresolved result. Stored classification/query values stay unchanged. Status facet counts must honor all other active filters, including Deviation; only their own status selection is excluded to keep alternatives reachable. Selecting or clearing a status must never silently reset Deviation. Selected zero-hit values stay visible and removable. This supersedes the earlier Result DB exception that ignored and automatically cleared Deviation. Result DB still counts individual comparisons, while Work counts rolled-up settings; no extra public observation types or invented statuses are introduced. +- Explicit user correction 2026-09-18: Result DB uses the shared Work vocabulary for its GPO `Result Status` filter and details: `Deviation`, `Passed`, `Not configured`, `Conflicts`, and `Duplicates`; `Review required` remains an independent unresolved result. Stored classification/query values stay unchanged. Status facet counts must honor all other active filters, including Deviation; only their own status selection is excluded to keep alternatives reachable. Selecting or clearing a status must never silently reset Deviation. Selected zero-hit values stay visible and removable. This supersedes the earlier Result DB exception that ignored and automatically cleared Deviation. Result DB still counts individual comparisons, while Work counts rolled-up settings; explicit user decision 2026-10-06 (policy 3.8.0, supersedes the same day's 3.7.0 collapse of identical rows) supersedes this for GPO baseline comparisons: like Work, the Result DB stores one row per GPO setting (project, aggregation run, scope and technical identity; the identity is used for grouping only and stays withheld), never one per compared baseline release. The row lists every compared baseline family, carries every distinct expectation, takes the strongest verdict in Work's order (Fail before Review required before Pass before Not configured), keeps the id of the member Work references first and records a content-free `baseline-comparisons-combined` trace entry. A real GPO duplicate stays one setting with its `Duplicates` flag; different settings are never combined, even with identical visible content; the concrete release stays withheld; no extra public observation types or invented statuses are introduced. ## GPO Registry baseline comparison diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 2040e3d..d50f6e3 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3416,6 +3416,12 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek 2026-10-06 - Phase 13F / Windows-Code-Bedeutungen in der GPO-ISO-Mapping-Anfrage (Engine-Arbeit, per Test verifiziert): Die Anfrage nennt zu Einstellungsname und empfohlenem Baselinewert jetzt die dokumentierte Bedeutung standardisierter Windows-Codes aus derselben Liste, die der Finding Reader anzeigt (`src/shared/windows-codes.ts`): `Setting name: Se Backup Privilege (Back up files and directories)`, `*S-1-5-32-544 (Administrators)`. Nur feste oeffentliche Microsoft-Namen; Domaenen-SIDs werden wie bisher nur ueber ihre Standard-RID benannt, der Kundenwert wird nie gelesen. `REGULATORY_GPO_QUERY_VERSION` 1.0.0 -> 1.1.0; der naechste Batch erzeugt GPO-Mappings neu, Scanner-Mappings und bestehende Entscheidungen bleiben. Keine Core-Aenderung, keine Produktversionsanhebung (Engine-Verfeinerung im laufenden Stand 2.19.0). Korrektur desselben Tages: Die GPO-Query-Version ging nur in die Mapping-ID ein, nicht in das eindeutige Identitaetstupel der Tabelle `assessor_regulatory_mappings` (rule_key, corpus_version, retrieval_version, prompt_version, model). Ein Neulauf alter GPO-Regeln scheiterte daher mit `Regulatory mapping identity conflicted with another pipeline tuple.`. GPO-Laeufe speichern ihre `promptVersion` jetzt als `1.2.0+gpo-baseline-context-1.1.0`; Scanner-Laeufe und alle IDs bleiben unveraendert, keine Migration. Zweite Korrektur: Entscheidungen gelten global je Regel, das Fenster `Map ISO controls` listete aber nur Kontrollen des aktuellen Laufs. Eine von einem frueheren Lauf automatisch akzeptierte Kontrolle, die der neue Lauf nicht mehr abruft, blieb in der Finding-Reader-Sektion `ISO controls` (ohne Titel) und liess sich nicht ablehnen. Die Antwort traegt jetzt `earlierControls` (entschiedene, im aktuellen Lauf nicht abgerufene Kontrollen, Titel aus aelteren Laeufen der Regel); das Fenster zeigt sie unter `Earlier decisions` mit Accept/Reject, die Sektion uebernimmt ihren Titel. Synthetischer Interaktionstest (Reject auf einer frueheren Kontrolle) und Servicetest. Dritte Korrektur: Der Store verlangt, dass eine Entscheidung auf einen Lauf zeigt, der die Kontrolle abgerufen hat (`Decision control was not a grounded candidate.`); eine Entscheidung zu einer frueheren Kontrolle verweist deshalb auf den neuesten aelteren erfolgreichen Lauf der Regel, der sie abgerufen hat. Der Servicetest lehnt die fruehere Kontrolle jetzt ab. +2026-10-06 - Phase 6 / Result DB: eine GPO-Zeile je Einstellung wie Work, Disclosure Policy 3.8.0 (Nutzerentscheidung Option b nach Zaehlvergleich Work 948 / Result DB 1.111 auf Dummy-Daten; Core-Freigabe Normalization, Disclosure, Core Tests, Governance; per Test verifiziert): Ersetzt den Eintrag 3.7.0 darunter. Fehler in 3.7.0: Die offengelegte Zeile traegt keine technische Setting-ID, daher fasste der Inhaltsvergleich auch verschiedene Einstellungen mit gleichem sichtbarem Inhalt zusammen (710 statt 929 bewerteter Einstellungen sichtbar). Jetzt gruppiert `materializeResultDb` GPO-Vergleiche nach Projekt, Aggregationslauf, Scope und technischer Identitaet (nur zur Gruppierung, weiterhin zurueckgehalten), wie Works `settingKey`. Die Zeile nennt alle verglichenen Baseline-Familien, fuehrt jede eindeutige Erwartung, uebernimmt das staerkste Urteil in Works Reihenfolge, behaelt die ID des Vergleichs, den Work zuerst referenziert (Compliance-Engine-Verweise bleiben gueltig), und vermerkt `baseline-comparisons-combined` im Trace. Erwartete Restdifferenz zu Work: Einstellungen mit `No baseline rule` oder `Not comparable` haben keinen Baselinevergleich und sind keine Result-DB-Vergleichszeilen. Produktversion weiterhin nicht erhoeht (Platform nicht freigegeben). Test: `tests/domain/result-db-materialization.test.ts`, inklusive verschiedener Einstellungen mit identischem sichtbarem Inhalt. + +2026-10-06 - Phase 6 / Result DB: identische GPO-Vergleichszeilen zusammengefasst, Disclosure Policy 3.7.0 (Nutzerentscheidung; Core-Freigabe Disclosure, Normalization, Core Tests, Governance; per Test verifiziert): Die GPO-Evaluation speichert je verglichenem Baseline-Release einen Vergleich. Da die Result DB nur die Baseline-Familie offenlegt, erschienen Vergleiche derselben Einstellung gegen mehrere Releases als identische Zeilen (lokal inhaltsfrei gezaehlt: 3.873 GPO-Zeilen, 2.762 davon inhaltsgleich mit einer anderen Zeile derselben Einstellung). `materializeResultDb` speichert GPO-Vergleiche derselben Einstellung und desselben Zeitfensters, deren vollstaendige offengelegte Zeile identisch ist, jetzt einmal und vermerkt `identical-comparisons-collapsed` im inhaltsfreien Trace. Andere Baseline-Familie, Erwartung, Ergebnis oder Diagnose behalten ihre eigene Zeile; Scannerfindings werden nie zusammengefasst; ein echtes GPO-Duplikat bleibt ein Vergleich mit `Duplicates`. Damit gilt die bisherige Regel "Result DB zaehlt einzelne Vergleiche" fuer diesen Fall nicht mehr (AGENTS.md ergaenzt). Die Policy-Erhoehung baut die gespeicherte Result DB ohne Reimport neu auf. Produktversion nicht erhoeht: `package.json` (Platform) war fuer diese Anfrage nicht freigegeben; Patch-Erhoehung im naechsten Release-Batch vorgesehen. Test: `tests/domain/result-db-materialization.test.ts`. + +2026-10-06 - Phase 13F / Client-Info-Abschnitt `ISO controls` nach Themen gruppiert (Nutzerentscheidung Option B nach Feedback "wird zu gross"; Core-Freigabe Governance, Presentation; per Test verifiziert): Statt einer Kachel je akzeptierter Kontrolle zeigt der Abschnitt die Kontrollen nach ISO-27002-Thema in ISO-Reihenfolge (Themen ohne Treffer entfallen). Jede Themenueberschrift nennt Kontroll- und eindeutige Findinganzahl und oeffnet Work mit `Deviation` und dem Thema in der Facette `ISO theme` (neue Drilldown-Verdrahtung in `DashboardPanel.tsx`). Darunter fliessen kompakte Zeilen in Spalten doppelter Kompaktkachelbreite, nach Findinganzahl sortiert: Annex-A-ID, vollstaendiger umbrechender Titel, dekorativer neutraler Mengenbalken (`--client-info-neutral`, relativ zur groessten Kontrolle) und massgebliche Zahl; eine Zeile oeffnet Work wie bisher die Kachel. Die Projektregeln halten die Entscheidung neben der unveraenderten vom 2026-10-05 fest. Verifikation: `npm test` mit 2.122 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich; inhaltsfreie DOM-Messung im laufenden Client bei 1440 px: drei Spalten je Thema, kein Abschneiden, kein Ueberlauf, 14/16-px-Rollen. Beobachtung: bei 20 Kontrollen 622 px Hoehe, weil lange Titel in rund der Haelfte der Zeilen umbrechen; die gruppierte Darstellung ist nicht automatisch niedriger als die Kachelflaeche. Nachtrag desselben Tages (Nutzerauftrag, nur Engine-Code): Der Mengenbalken ist entfernt; die Zeile zeigt nur noch ID, Titel und Zahl. Messung bei 1440 px: 601 px statt 622 px; der Titel erhaelt 336 px statt rund 266 px, die Haelfte der Zeilen bricht weiterhin zweizeilig um, weil die ISO-Titel lang sind. Der Projektregeltext nennt den Balken noch und wird erst mit erneuter Governance-Freigabe angeglichen. Zweiter Nachtrag desselben Tages (Nutzerauftrag mit Core-Freigabe Governance): Je Thema erscheinen die sechs Kontrollen mit den meisten Findings (`ISO_CONTROLS_PER_THEME`); darunter erweitert `Show all N controls` dieses Thema und `Show top 6` klappt es wieder ein (`aria-expanded`). Jedes Thema startet fuer einen neuen Timeslot eingeklappt, eine gespeicherte Entscheidung laesst ein geoeffnetes Thema offen; die Themenueberschrift oeffnet weiter alle Kontrollen des Themas in Work. Der Regeltext ist angeglichen (Balken entfernt, Begrenzung ergaenzt). Synthetischer Interaktionstest fuer Begrenzung, Erweitern, Erhalt nach Entscheidung und Einklappen. Messung bei 1440 px: eingeklappt 459 px mit 11 Zeilen, erweitert 633 px mit 20 Zeilen; kein Abschneiden oder Ueberlauf, Button 28 px/13 px. Dritter Nachtrag desselben Tages (Nutzerentscheidung: eigene Compliance-Zeile, Kachelfakten Controls/Findings/zwei Themen; Core-Freigabe Presentation, Governance): Die Liste steht nicht mehr in der Seite. Client Info zeigt eine Zeile \`Compliance\` mit einer Kachel je Rahmenwerk in exakt der Umgebungskachel-Geometrie; die Kachel \`ISO 27001/27002\` zeigt \`Controls\`, \`Findings\` und die zwei Themen mit den meisten Findings (Gedankenstrich waehrend des Ladens), ihr \`Results\`-Fenster die vollstaendige Themenliste ohne Begrenzung; Themenueberschrift und Zeile schliessen das Fenster und oeffnen Work. Die Sechserbegrenzung entfaellt. \`DashboardFactList\`, \`DashboardGlyph\` und \`DashboardFact\` liegen jetzt im Core-Modul \`src/features/dashboard/ClientTileParts.tsx\` (Presentation), damit die Engine sie ohne Zirkelimport wiederverwendet; \`DashboardPanel\` re-exportiert \`DashboardFact\`. Nebenbei behoben: ein Einfuegefehler des zweiten Nachtrags hatte die Hover-Regel der Themenueberschrift zerstoert, sodass Themennamen immer unterstrichen waren. Synthetische Interaktionstests: Kachelfakten, Gedankenstrich beim Laden, Fenster ueber den \`Results\`-Button, Drilldown aus Zeile und Thema mit Schliessen, Neuladen nach Entscheidung, leerer Zustand. Messung bei 1440 px: Kachel 260 x 260 px wie die Umgebungskacheln, vier Fakten in 14/16 px ohne Scrollen, gesamte Zeile 308 px statt 601 px; im Fenster 20 Zeilen in zwei Spalten ohne Abschneiden oder Ueberlauf, Escape schliesst. Die Zeile hat eine eigene Klasse \`client-compliance-grid\` mit einem separaten Regelblock gleicher Werte in \`datahub.css\`; ein erster Versuch mit der gemeinsamen Klasse \`client-environment-grid\` liess bestehende Core-Tests sieben statt sechs Umgebungskacheln zaehlen und wurde verworfen. Live erneut gemessen: sechs Umgebungskacheln, ISO-Kachel 260 x 260 px mit gleicher Flaeche, Titelgroesse und linker Kante. Helles Theme nicht gesondert geprueft; die Kachel nutzt dieselben Tokens wie die Umgebungskacheln. Vierter Nachtrag desselben Tages (Nutzerauftrag, nur Engine-Code): Die Kachel zeigt nach \`Controls\` und \`Findings\` alle vier Themen in ISO-Reihenfolge, je mit Findinganzahl (\`0 findings\` ohne Treffer, Gedankenstrich beim Laden). Ein Thema mit Treffern ist ueber die ganze Zelle klickbar und oeffnet Work mit \`Deviation\` und dem Thema in \`ISO theme\` (bestehende Verdrahtung); ein Thema ohne Treffer ist nicht klickbar, weil die Facette nur Werte mit Treffern anbietet. Hover aendert nur die Flaeche, Tastaturfokus zeigt den blauen Ring. Die Engine rendert dafuer das Faktenraster selbst mit denselben Klassen statt \`DashboardFactList\`; fuer diese Kachel bleiben alle sechs Fakten auch unterhalb der 294-px-Schwelle sichtbar, unter der Umgebungskacheln zwei zeigen. Messung: bei 1440 px Kachel 260 x 260 px, bei 1920 px 300 px, jeweils sechs Fakten ohne Scrollen oder Ueberlauf; alle vier Ecken jeder klickbaren Zelle treffen den Button; ein echter Klick oeffnet Work mit gesetzter \`ISO theme\`-Auswahl. Der Projektregeltext nennt noch "die zwei Themen mit den meisten Findings" und wird erst mit Governance-Freigabe angeglichen. Nachtrag mit Core-Freigabe Governance, Platform: Die Projektregeln halten die Vier-Themen-Kachel als spaetere Entscheidung desselben Tages fest. Die Produktversion bleibt 2.19.0, weil die Versionsvertragstests (`tests/app/app-version.test.ts`, `tests/server/assessor-http-server.test.ts`) zum Bereich Core Tests gehoeren, der nicht freigegeben war; der Patch-Schritt auf 2.19.1 steht weiter aus. Produktversion unveraendert 2.19.0; ein Patch-Schritt steht aus, weil Platform fuer diese Anfrage nicht freigegeben war. Kein Reimport, keine Datenaenderung. + 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. diff --git a/src/compliance/ui/ClientIsoControls.tsx b/src/compliance/ui/ClientIsoControls.tsx index 6f094ca..dfdee36 100644 --- a/src/compliance/ui/ClientIsoControls.tsx +++ b/src/compliance/ui/ClientIsoControls.tsx @@ -2,11 +2,15 @@ import { useEffect, useId, useState } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import type { BackendStatus } from '../../app/app-state'; import { useEnginePreferences } from '../../app/engine-preferences'; -import type { RegulatoryControlOverviewResponse } from '../regulatory-mapping'; +import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts'; +import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton'; +import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping'; import { useRegulatoryDecisionRevision } from './decision-revision'; import './compliance.css'; const assessorApi = new AssessorApiClient(); +const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological']; +export const ISO_TILE_TITLE = 'ISO 27001/27002'; interface Props { projectId?: string; @@ -15,12 +19,36 @@ interface Props { backendStatus: BackendStatus; /** Opens Work filtered to this accepted control, like a Context heatmap cell. */ onOpenControl(controlId: string): void; + /** Opens Work filtered to this ISO theme. */ + onOpenTheme(theme: RegulatoryControlTheme): void; } type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse }; -/** Client Info `ISO controls`: accepted controls of the timeslot's deviations, laid out like the Context heatmap. */ -export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl }: Props) { +const number = (value: number) => value.toLocaleString('en-US'); +const count = (value: number, noun: string) => `${number(value)} ${noun}${value === 1 ? '' : 's'}`; +const byFindings = (left: RegulatoryControlOverviewItem, right: RegulatoryControlOverviewItem) => + right.findings.length - left.findings.length || left.controlId.localeCompare(right.controlId, 'en', { numeric: true }); + +interface ThemeGroup { theme?: RegulatoryControlTheme; controls: RegulatoryControlOverviewItem[]; findings: number } + +/** Themes in ISO 27002 order, only with hits; a control without a known theme follows without a drilldown. */ +function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] { + return [...THEMES, undefined] + .map(theme => { + const themeControls = controls.filter(control => control.theme === theme).sort(byFindings); + // A finding with several controls of one theme counts once for that theme. + const findings = new Set(themeControls.flatMap(control => control.findings.map(finding => finding.resultProjectionId))).size; + return { theme, controls: themeControls, findings }; + }) + .filter(group => group.controls.length > 0); +} + +/** + * Client Info `Compliance` row: one tile per framework in the environment tile geometry, the complete + * control list in its Results window. ISO/IEC 27001/27002 is the first framework. + */ +export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme }: Props) { const available = useEnginePreferences().compliance; const revision = useRegulatoryDecisionRevision(); const id = useId(); @@ -39,23 +67,68 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = }, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps if (!available || state.status === 'idle') return null; - return
-
-

ISO controls

- {state.status === 'ready' && - Findings: {state.overview.findingCount.toLocaleString('en-US')}} -
- {state.status === 'error' &&

ISO controls could not be loaded.

} - {state.status === 'ready' && !state.overview.controls.length &&

No accepted ISO controls in this timeslot.

} - {state.status === 'ready' && state.overview.controls.length > 0 &&
- {state.overview.controls.map(control => )} -
} + const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : []; + // Pending values stay an em dash, never zero; all four themes follow the totals in ISO order. + const ready = state.status === 'ready'; + const totals: DashboardFact[] = [ + { label: 'Controls', value: ready ? number(state.overview.controls.length) : '—' }, + { label: 'Findings', value: ready ? number(state.overview.findingCount) : '—' }]; + const themeFindings = (theme: RegulatoryControlTheme) => groups.find(group => group.theme === theme)?.findings ?? 0; + // Same cell markup as the shared fact grid; a theme with hits opens Work filtered to it. + const tileFacts =
+ {totals.map(fact =>
{fact.label}
{fact.value}
)} + {THEMES.map(theme => { + const findings = themeFindings(theme); + return
0 ? 'iso-tile-theme is-actionable' : 'iso-tile-theme'} data-theme={theme}> +
{theme}
+
{!ready ? '—' : findings > 0 + ? + : count(0, 'finding')}
+
; + })} +
; + + const results = (dismiss: () => void) => { + const open = (action: () => void) => { dismiss(); action(); }; + if (state.status !== 'ready') return null; + if (!groups.length) return

No accepted ISO controls in this timeslot.

; + return
+

Findings: {number(state.overview.findingCount)}

+
+ {groups.map(({ theme, controls, findings }) => { + const summary = <>{theme ?? 'No ISO theme'} + {count(controls.length, 'control')} · {count(findings, 'finding')}; + return
+ {theme + ? + :
{summary}
} +
{controls.map(control => )}
+
; + })} +
+
; + }; + + return
+

Compliance

+ {/* Same tile geometry as the environment row; later frameworks add their tiles here. */} +
+
+
+

{ISO_TILE_TITLE}

+ {state.status === 'ready' && {results}} +
+ {state.status === 'error' ?

ISO controls could not be loaded.

: tileFacts} +
+
; } diff --git a/src/compliance/ui/compliance.css b/src/compliance/ui/compliance.css index daa8670..1041e10 100644 --- a/src/compliance/ui/compliance.css +++ b/src/compliance/ui/compliance.css @@ -9,29 +9,49 @@ .finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; } .finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } -/* Client Info section, laid out like the Context heatmap with neutral cells. */ -.iso-controls { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; } -.iso-controls h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } -.iso-controls p { margin: var(--space-2) 0; font-size: var(--type-body-size); } -.iso-controls__header { display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; } -.iso-controls__total { font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } -.iso-controls__empty { color: var(--text-secondary); } -.iso-controls__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; +/* Client Info `Compliance` row; its tiles use the environment tile geometry. */ +.client-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; } +.client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } +.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); } +/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */ +.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; } +/* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */ +.client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; } +.client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); } +.client-compliance-grid .iso-tile-theme.is-actionable:has(button:focus-visible) { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -1); } +.iso-tile-theme button { + padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; } -.iso-controls__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(--surface-panel-raised); color: var(--text-primary); - font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere; +.iso-tile-theme button::after { content: ''; position: absolute; inset: 0; } +.iso-tile-theme button:focus-visible { outline: none; } + +/* ISO Results window: accepted controls grouped by ISO 27002 theme as ranked rows. */ +.iso-controls { color: var(--text-primary); min-width: 0; } +.iso-controls__total { margin: 0; font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.iso-controls__empty { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); } +.iso-controls__themes { display: grid; gap: var(--space-3); margin-top: var(--space-3); } +.iso-controls__group { display: grid; gap: var(--space-1); min-width: 0; } +/* Rows fill columns of twice the compact tile width; a partial last row keeps its track width. */ +.iso-controls__rows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, calc(var(--tile-compact-width) * 2)), 1fr)); gap: var(--space-1) var(--space-3); } +.iso-controls__theme { + display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; + padding: var(--space-2) var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); + background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; } -.iso-controls__cell:hover { border-color: currentColor; } -.iso-controls__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); } -.iso-controls__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); } -.iso-controls__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); } -.iso-controls__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); } +button.iso-controls__theme { cursor: pointer; } +button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: underline; } +.iso-controls__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); } +.iso-controls__theme-count { font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.iso-controls__row { + display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: var(--space-3); + padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--radius-sm); + background: var(--tile-surface); color: var(--text-primary); font-family: inherit; text-align: left; cursor: pointer; +} +.iso-controls__row:hover { background: var(--tile-surface-hover); } +.iso-controls__row:focus-visible, button.iso-controls__theme:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); } +.iso-controls__id { font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.iso-controls__title { font-size: var(--type-body-size); line-height: var(--lh-ui); overflow-wrap: break-word; } +.iso-controls__amount { font-size: var(--type-body-size); font-weight: var(--fw-semibold); text-align: right; font-variant-numeric: tabular-nums; } .regulatory-earlier-controls { margin-top: 16px; } .regulatory-earlier-controls > h4 { margin-bottom: 4px; } .regulatory-earlier-controls > p { color: var(--text-muted); } diff --git a/src/domain/result-db-disclosure.ts b/src/domain/result-db-disclosure.ts index 5673465..37ccf30 100644 --- a/src/domain/result-db-disclosure.ts +++ b/src/domain/result-db-disclosure.ts @@ -6,7 +6,7 @@ import { wellKnownSidName } from '../shared/windows-codes'; import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace'; -export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.6.0'; +export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.8.0'; const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z'; diff --git a/src/domain/result-db-materialization.ts b/src/domain/result-db-materialization.ts index 603b2d9..c8419e9 100644 --- a/src/domain/result-db-materialization.ts +++ b/src/domain/result-db-materialization.ts @@ -1,5 +1,6 @@ import { discloseResultDbProjectionWithTrace, RESULT_DB_DISCLOSURE_POLICY_VERSION } from './result-db-disclosure'; import { RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; +import { policyBaselineVariantDecision } from './policy-evaluation'; import type { UnifiedResultProjection } from './unified-result'; /** @@ -32,7 +33,8 @@ export function materializeResultDb( sourceRevision: number, materializedAt: string ): MaterializedResultDb { - const rows = sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId))); + const rows = rollupPolicyComparisons(sourceResults, + sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId)))); return { rows, status: { @@ -50,6 +52,72 @@ export function materializeResultDb( }; } +/** Work's verdict priority for a rolled-up GPO setting: Fail before Review required before Pass. */ +const classificationPriority: Record = { + misconfigured: 6, 'review-required': 5, conflict: 5, match: 4, 'not-configured': 3 +}; + +/** + * User decision 2026-10-06: like Work, the Result DB shows one row per GPO setting (scope and + * technical identity of one aggregation run) instead of one per compared baseline release, whose + * concrete release is withheld anyway. The row lists every compared baseline family, combines their + * distinct expectations and takes the verdict of the strongest comparison. Its id is the member Work + * references first, so links from Work and the Compliance Engine keep resolving. A GPO duplicate is + * one comparison with its `Duplicates` flag and is not affected. + */ +function rollupPolicyComparisons(sources: UnifiedResultProjection[], rows: UnifiedResultProjection[]): UnifiedResultProjection[] { + const groups = new Map(); + const output: Array = []; + rows.forEach((row, index) => { + const source = sources[index]; + if (!row.resultProjectionId.startsWith('policy-evaluation-comparison:') || !source.policyIdentityKey) { + output.push(row); + return; + } + const key = JSON.stringify([source.projectId, source.collectionRunId, source.policyScope, source.policyIdentityKey]); + const group = groups.get(key); + if (group) group.push(row); + else { + const created = [row]; + groups.set(key, created); + output.push(created); + } + }); + return output.map(entry => Array.isArray(entry) ? combinePolicyComparisons(entry) : entry); +} + +function combinePolicyComparisons(group: UnifiedResultProjection[]): UnifiedResultProjection { + if (group.length === 1) return group[0]; + const reference = [...group].sort((a, b) => a.sourceResultId.localeCompare(b.sourceResultId))[0]; + const verdict = group.reduce((left, right) => (classificationPriority[right.policyClassification ?? ''] ?? 0) + > (classificationPriority[left.policyClassification ?? ''] ?? 0) ? right : left); + const unique = (values: T[]) => [...new Map(values.map(value => [JSON.stringify(value), value])).values()]; + const families = [...new Set(group.map(row => row.policyBaselineLabel).filter((label): label is string => Boolean(label)))].sort(); + const baselineValues = unique(group.map(row => row.policyBaselineValue)); + const assessments = group.map(row => row.policyBaselineVariantAssessment); + const variants = unique(assessments.flatMap(assessment => assessment?.variants ?? []) + .map(variant => ({ baselineValues: variant.baselineValues, comparator: variant.comparator }))) + .map((variant, index) => ({ settingId: `baseline-expectation-${index + 1}`, ...variant })); + const blockers = [...new Set(assessments.flatMap(assessment => assessment?.diagnosticCodes ?? []))].sort(); + const diagnostics = [...new Set(group.flatMap(row => row.policyDiagnosticCodes ?? []))].sort(); + const trace = reference.disclosureTrace!; + return { + ...verdict, + resultProjectionId: reference.resultProjectionId, + sourceResultId: reference.sourceResultId, + policyBaselineLabel: families.join('; ') || undefined, + policyBaselineValue: baselineValues.length === 1 ? baselineValues[0] : undefined, + policyBaselineVariantAssessment: assessments.every(Boolean) ? { + version: 1, scope: 'backup-configuration', actualValues: [], variants, diagnosticCodes: blockers, + decision: assessments.some(assessment => assessment!.decision === 'unavailable') ? 'unavailable' + : policyBaselineVariantDecision(variants, blockers) + } : undefined, + policyDiagnosticCodes: diagnostics.length ? diagnostics : undefined, + disclosureTrace: { ...trace, redactions: [...trace.redactions, + { field: 'row', rule: 'baseline-comparisons-combined', count: group.length - 1 }] } + }; +} + /** A stored state is current only for the same repository revision and the same policy versions. */ export function isResultDbCurrent(status: Pick | undefined, repositoryRevision: number): boolean { return status?.sourceRevision === repositoryRevision diff --git a/src/features/dashboard/ClientTileParts.tsx b/src/features/dashboard/ClientTileParts.tsx new file mode 100644 index 0000000..0989de2 --- /dev/null +++ b/src/features/dashboard/ClientTileParts.tsx @@ -0,0 +1,29 @@ +/** Client Info tile parts shared by Core tiles and engine tiles in Client Info extension points. */ + +export interface DashboardFact { + label: string; + value: string; +} + +export type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan'; + +export function DashboardFactList({ items }: { items: DashboardFact[] }) { + return
+ {items.map(item =>
{item.label}
{item.value}
)} +
; +} + +export function DashboardGlyph({ name }: { name: DashboardGlyphName }) { + const content = name === 'user' ? <> + : name === 'computer' ? <> + : name === 'server' ? <> + : name === 'policy' || name === 'clipboard' ? <> + : name === 'cloud' ? + : name === 'shield' || name === 'scan' ? <> + : name === 'lock' ? <> + : name === 'code' ? <> + : name === 'chart' ? <> + : name === 'folder' ? + : <>; + return ; +} diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 0edbff6..a9b1f6d 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -1,7 +1,7 @@ import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences'; import { ClientContextHeatmap } from '../context-layer/ContextHeatmap'; import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls'; -import { ISO_CONTROL_FACET_ID } from '../../compliance/ui/compliance-work-facets'; +import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets'; 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'; @@ -14,6 +14,8 @@ import { downloadText } from '../../exports/text-download'; import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter'; import { OperatingSystemInventory } from './OperatingSystemInventory'; import { EnvironmentResultsButton } from './EnvironmentResultsButton'; +import { DashboardFactList, DashboardGlyph, type DashboardFact, type DashboardGlyphName } from './ClientTileParts'; +export type { DashboardFact } from './ClientTileParts'; import { CloudInformationTile } from './CloudInformationTile'; import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables'; import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence'; @@ -567,10 +569,6 @@ export interface DashboardEnvironmentDetail { restricted: string[]; } -export interface DashboardFact { - label: string; - value: string; -} const UNKNOWN_FACT = '—'; @@ -763,11 +761,6 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false, } /** Source-backed summary facts appear in the tiles and remain available in Results. */ -function DashboardFactList({ items }: { items: DashboardFact[] }) { - return
- {items.map(item =>
{item.label}
{item.value}
)} -
; -} function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: { title: string; items: DashboardFact[]; glyph: DashboardGlyphName; @@ -879,7 +872,6 @@ export function DashboardDonutCard({ title, meta, initiallyExpanded = true, chil
; } -type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan'; interface DashboardSourceScore { tool: DashboardRiskTool; @@ -1050,20 +1042,6 @@ function dashboardGpoCount(records: FindingRecord[]): number | undefined { return identifiers.size || undefined; } -function DashboardGlyph({ name }: { name: DashboardGlyphName }) { - const content = name === 'user' ? <> - : name === 'computer' ? <> - : name === 'server' ? <> - : name === 'policy' || name === 'clipboard' ? <> - : name === 'cloud' ? - : name === 'shield' || name === 'scan' ? <> - : name === 'lock' ? <> - : name === 'code' ? <> - : name === 'chart' ? <> - : name === 'folder' ? - : <>; - return ; -} /** * Verbindungskurven zwischen Overall Score und den Security Domains. @@ -1678,7 +1656,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] }); }, false)} /> - {/* Compliance Engine: accepted ISO controls of this timeslot; a tile selects its control in the Work ISO control filter. */} + {/* Compliance Engine: `Compliance` row with one tile per framework; its Results window opens a control or theme in Work. */} jump(() => { const targetSheet = sourceScanNavigationSheet(dataset, activeSheet); @@ -1687,6 +1665,14 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { setStatusFilter('deviation'); } setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] }); + }, false)} + onOpenTheme={theme => jump(() => { + const targetSheet = sourceScanNavigationSheet(dataset, activeSheet); + if (targetSheet) { + app.setActiveSheet(targetSheet.id); + setStatusFilter('deviation'); + } + setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] }); }, false)} /> {scoringOpen && setScoringOpen(false)} />} diff --git a/src/styles/datahub.css b/src/styles/datahub.css index 21109d7..ce75a29 100644 --- a/src/styles/datahub.css +++ b/src/styles/datahub.css @@ -386,6 +386,17 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); } .client-environment-grid .client-env-group-title, .client-command-center .client-environment-grid > .client-os-inventory > header h3 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } +/* Compliance Engine row (Client Info extension point): framework tiles with the exact environment tile + geometry and fact grid, kept as separate rules so the six-tile environment row stays unchanged. */ +.client-compliance-grid { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); align-items: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; } +.client-compliance-grid > .client-env-group { display: flex; flex-direction: column; min-width: 0; aspect-ratio: 1; container: environment-tile / inline-size; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); overflow: auto; } +.client-compliance-grid > section > header { align-items: flex-start; gap: var(--space-1); } +.client-compliance-grid .client-environment-results-button { margin-top: 0; margin-inline-start: auto; } +.client-compliance-grid > section > .client-env-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: var(--space-2) 0 0; padding: 0; border: 0; background: var(--tile-surface); } +.client-compliance-grid > section > .client-env-facts > div { min-height: 0; padding: var(--space-1) var(--space-2); border: var(--border-hair) solid var(--client-info-line); background: var(--tile-surface); } +.client-compliance-grid > section > .client-env-facts > div.is-wide { grid-column: 1 / -1; } +.client-compliance-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); } +.client-compliance-grid .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } .client-env-group { min-width: 0; } .client-env-group-header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); } .client-env-group-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin: 0; color: var(--client-info-muted); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; } diff --git a/tests/compliance/client-iso-controls.test.tsx b/tests/compliance/client-iso-controls.test.tsx index 346d2de..4b769ab 100644 --- a/tests/compliance/client-iso-controls.test.tsx +++ b/tests/compliance/client-iso-controls.test.tsx @@ -13,8 +13,9 @@ import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-re import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup'; import { makeSheet } from '../../src/importers/sheet-factory'; -// Client Info `ISO controls`: one tile per accepted control with its number of findings, laid out like -// the Context heatmap; a tile opens exactly its findings in Work. Switched off, nothing renders or loads. +// Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes +// with the most findings; its Results window lists every control by theme and opens Work from a control or +// a theme. Switched off, nothing renders or loads. const overview: RegulatoryControlOverviewResponse = { version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3, controls: [ @@ -22,6 +23,10 @@ const overview: RegulatoryControlOverviewResponse = { { resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' }, { resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] }, { controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [ + { resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] }, + { controlId: '8.9', annexAControlId: 'A.8.9', title: 'Configuration management', theme: 'Technological', findings: [ + { resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' }, + { resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }, { resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] } ] }; @@ -29,9 +34,10 @@ const overview: RegulatoryControlOverviewResponse = { let host: HTMLDivElement; let root: Root; const onOpenControl = vi.fn(); +const onOpenTheme = vi.fn(); const render = () => act(async () => { root.render(); + backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}/>); await new Promise(resolve => setTimeout(resolve, 0)); }); @@ -40,6 +46,7 @@ beforeEach(() => { localStorage.clear(); resetEnginePreferencesForTests(); onOpenControl.mockReset(); + onOpenTheme.mockReset(); host = document.createElement('div'); document.body.append(host); root = createRoot(host); @@ -53,29 +60,77 @@ afterEach(async () => { }); describe('Client Info ISO controls', () => { - it('shows each accepted control with its finding count and opens exactly its findings', async () => { + const facts = () => [...host.querySelectorAll('[data-framework="iso-27001-27002"] .client-env-facts > div')] + .map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]); + const resultsButton = () => host.querySelector('[data-framework="iso-27001-27002"] .client-environment-results-button'); + const openResults = () => act(async () => resultsButton()!.click()); + const windowGroups = () => [...document.querySelectorAll('.iso-controls__group')]; + + it('shows a Compliance tile with totals and all four themes, and opens Work from a theme with hits', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview); await render(); expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] }); - expect(host.querySelector('h2')?.textContent).toBe('ISO controls'); - expect(host.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3'); - const tiles = [...host.querySelectorAll('.iso-controls__cell')]; - expect(tiles.map(tile => [...tile.children].map(part => part.textContent))).toEqual([ - ['Technological', 'A.8.20 · Networks security', '2 findings'], - ['Organizational', 'A.5.15', '1 finding'] + expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance'); + // Same tile geometry as the environment row. + expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull(); + expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002'); + // All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile. + expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'], + ['Physical', '0 findings'], ['Technological', '3 findings']]); + expect(host.querySelector('.iso-controls__row')).toBeNull(); + // Only a theme with hits is a Work drilldown; Work's ISO theme facet offers no zero-hit value. + const themeButton = (theme: string) => host.querySelector(`[data-theme="${theme}"] button`); + expect(themeButton('People')).toBeNull(); + expect(themeButton('Physical')).toBeNull(); + expect(themeButton('Technological')?.getAttribute('aria-label')).toBe('Open Technological ISO theme findings in Work (3)'); + await act(async () => themeButton('Technological')!.click()); + expect(onOpenTheme).toHaveBeenCalledWith('Technological'); + await act(async () => themeButton('Organizational')!.click()); + expect(onOpenTheme).toHaveBeenLastCalledWith('Organizational'); + }); + + it('keeps pending values as an em dash and offers no Results before the overview loads', async () => { + vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined)); + await render(); + expect(facts()).toEqual([['Controls', '—'], ['Findings', '—'], ['Organizational', '—'], ['People', '—'], ['Physical', '—'], ['Technological', '—']]); + expect(host.querySelector('[data-theme] button')).toBeNull(); + expect(resultsButton()).toBeNull(); + }); + + it('opens the complete list by theme in the Results window and opens Work from a control or theme', async () => { + vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview); + await render(); + await openResults(); + expect(document.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3'); + // ISO 27002 theme order, only themes with hits, every control ranked by finding count. + expect(windowGroups().map(group => group.dataset.theme)).toEqual(['Organizational', 'Technological']); + expect(windowGroups().map(group => group.querySelector('.iso-controls__theme')?.textContent)).toEqual([ + 'Organizational1 control · 1 finding', 'Technological2 controls · 3 findings' ]); - await act(async () => tiles[0].click()); + const rows = (group: HTMLElement) => [...group.querySelectorAll('.iso-controls__row')] + .map(row => [...row.children].map(part => part.textContent)); + expect(rows(windowGroups()[0])).toEqual([['A.5.15', '', '1']]); + expect(rows(windowGroups()[1])).toEqual([['A.8.9', 'Configuration management', '3'], ['A.8.20', 'Networks security', '2']]); + await act(async () => windowGroups()[1].querySelectorAll('.iso-controls__row')[1].click()); expect(onOpenControl).toHaveBeenCalledWith('8.20'); + // Opening Work closes the window. + expect(document.querySelector('.iso-controls')).toBeNull(); + await openResults(); + await act(async () => windowGroups()[1].querySelector('button.iso-controls__theme')!.click()); + expect(onOpenTheme).toHaveBeenCalledWith('Technological'); + expect(document.querySelector('.iso-controls')).toBeNull(); }); it('reloads after a control decision and says so when nothing is accepted', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview') .mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview); await render(); - expect(host.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.'); + expect(facts().slice(0, 3)).toEqual([['Controls', '0'], ['Findings', '0'], ['Organizational', '0 findings']]); + await openResults(); + expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.'); await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(request).toHaveBeenCalledTimes(2); - expect(host.querySelectorAll('.iso-controls__cell')).toHaveLength(2); + expect(facts()[0]).toEqual(['Controls', '3']); }); it('renders nothing and requests nothing while the Compliance Engine is off', async () => { @@ -91,13 +146,14 @@ describe('Client Info ISO controls', () => { expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' }); }); - it('places the section below the Context heatmap and opens Work through the ISO control filter', () => { + it('places the section below the Context heatmap and opens Work through the ISO control and theme filters', () => { const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); expect(source.indexOf(' ({ + version: 1 as const, comparator: { kind: 'equality' as const }, relation, + actual: { kind: 'text' as const, value: '0' }, baseline: { kind: 'text' as const, value: expected }, diagnosticCodes: [] +}); + +/** One GPO baseline comparison, as the evaluation stores it per compared baseline release. */ +function comparison(id: string, overrides: Partial = {}, expected = '1'): UnifiedResultProjection { + const relation = overrides.policyClassification === 'match' ? 'match' : 'different'; + return { + resultProjectionId: `policy-evaluation-comparison:${id}`, sourceKind: 'policy-evaluation', sourceResultId: id, + projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'aggregation-1', evaluationRunId: 'evaluation-1', + sourceTool: 'GPOs', sourceRuleId: 'gpo-identity-1', title: 'Synthetic setting · SyntheticValue', + severityOriginal: null, severityNormalized: 'unknown', outcomeOriginal: 'confirmed_configured_deviation', + outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'policy_deviation', + isIoe: false, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1, + reviewStatus: 'unreviewed', importedAt: '2026-10-06T08:00:00.000Z', createdAt: '2026-10-06T08:00:00.000Z', + policyScope: 'computer', policyDeliveryMechanism: 'registry-policy', policyIdentityKey: 'gpo-identity-1', + policyBaselineId: `baseline-${id}`, policyBaselineLabel: 'Windows Server 2022 Security Baseline', + policyBaselineValue: { kind: 'text', value: expected }, policyActualValue: { kind: 'text', value: '0' }, + policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration', + decision: relation === 'match' ? 'all-pass' : 'all-fail', actualValues: [], + variants: [{ settingId: `setting-${id}`, baselineValues: [], comparator: comparator(relation, expected) }], diagnosticCodes: [] }, + policyComparisonRelation: relation, policyCustomerState: 'configured', policyClassification: 'misconfigured', + frameworkReferences: [], categories: [], + ...overrides + }; +} + +const passed = { outcomeNormalized: 'pass', isDeviation: false, policyClassification: 'match', + policyComparisonRelation: 'match' } as const; + +function gpoRows(sources: UnifiedResultProjection[]): UnifiedResultProjection[] { + return materializeResultDb(sources, new Map(), 1, '2026-10-06T08:00:00.000Z').rows + .filter(row => row.sourceKind === 'policy-evaluation'); +} + +describe('Result DB GPO comparisons: one row per setting like Work (user decision 2026-10-06)', () => { + it('combines every baseline release and family of one setting into one row', () => { + const rows = gpoRows([ + comparison('b-server-2025', { policyBaselineLabel: 'Windows Server 2025 Security Baseline' }), + comparison('a-server-2022'), + comparison('c-client', { policyBaselineLabel: 'Windows 11 v24H2 Security Baseline' }) + ]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + resultProjectionId: 'policy-evaluation-comparison:a-server-2022', + policyBaselineLabel: 'Microsoft Security Baseline - Windows Client; Microsoft Security Baseline - Windows Server', + policyBaselineValue: { kind: 'text', value: '1' } + }); + expect(rows[0].policyBaselineVariantAssessment?.variants).toHaveLength(1); + expect(rows[0].disclosureTrace?.redactions).toContainEqual({ field: 'row', rule: 'baseline-comparisons-combined', count: 2 }); + }); + + it('takes the strongest verdict and keeps every distinct expectation', () => { + const [row] = gpoRows([ + comparison('a-client', { ...passed, policyBaselineLabel: 'Windows 11 v24H2 Security Baseline' }, '0'), + comparison('b-server', {}, '1') + ]); + expect(row).toMatchObject({ outcomeNormalized: 'fail', isDeviation: true, policyClassification: 'misconfigured', + resultProjectionId: 'policy-evaluation-comparison:a-client', policyBaselineValue: undefined }); + expect(row.policyBaselineVariantAssessment).toMatchObject({ decision: 'mixed' }); + expect(row.policyBaselineVariantAssessment?.variants.map(variant => variant.comparator.baseline)) + .toEqual([{ kind: 'text', value: '0' }, { kind: 'text', value: '1' }]); + }); + + it('keeps different settings apart even when their visible content is identical', () => { + const rows = gpoRows([ + comparison('a'), + comparison('b', { policyIdentityKey: 'gpo-identity-2' }), + comparison('c', { policyScope: 'user' }), + comparison('d', { collectionRunId: 'aggregation-2', assessmentId: 'assessment-2', evaluationRunId: 'evaluation-2' }) + ]); + expect(rows).toHaveLength(4); + }); + + it('never combines scanner findings', () => { + const scanner = (id: string): UnifiedResultProjection => ({ + ...comparison(id), resultProjectionId: `scanner-finding:${id}`, sourceKind: 'scanner-finding', sourceTool: 'maester', + sourceRuleId: 'MT.1001', title: 'Synthetic rule', resultType: 'compliance_check' + }); + const { rows } = materializeResultDb([scanner('1'), scanner('2')], new Map(), 1, '2026-10-06T08:00:00.000Z'); + expect(rows).toHaveLength(2); + }); +}); diff --git a/tests/exports/unified-result-jsonl.test.ts b/tests/exports/unified-result-jsonl.test.ts index 84e6ea8..a4fdf81 100644 --- a/tests/exports/unified-result-jsonl.test.ts +++ b/tests/exports/unified-result-jsonl.test.ts @@ -49,7 +49,7 @@ describe('filtered Result DB JSONL export', () => { recordType: 'manifest', format: RESULT_DB_JSONL_FORMAT, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, - disclosurePolicyVersion: '3.6.0', + disclosurePolicyVersion: '3.8.0', handling: RESULT_DB_EXPORT_HANDLING, generatedOn: '2026-09-03', resultCount: 2,